Working with Cookies in Rails

The Complete Guide to Working With Cookies in Rails

This post covers almost everything you need to know about HTTP cookies in the context of Rails. We'll explore what a cookie is, why we need it, how to set & get a cookie, how to restrict cookies to a particular domain/path, prevent JavaScript access, how to sign & encrypt cookies, and much more.

14 min read
🎁
If you want to work with Hotwire and are based in USA, California-based virtual-inspection software company Sitewire is looking for a new Rails developer to join their team.

In addition to Rails, they use Hotwire (Turbo + Stimulus) and TailwindCSS, which in my opinion is the best tech stack to build web applications in 2023. Check out their job opening for the Full-stack Rails Engineer role. It's remote (only in the US), and if you think you’re a suitable candidate, get in touch with Justin.

You must have seen those cookie banners on most websites nowadays. Ever wondered what cookies are and why websites use them?

In this chapter, we're going to learn almost everything you need to know about cookies. We'll learn what they are, how they work, and why they're so important. We'll also cover some advanced topics like restricting cookie access and scope along with preventing tampering by signing and encrypting a cookie.

This post also explores the elegant cookies API in Rails, including some of the best practices for working with cookies to keep your data secure. By the end of this article, I think you'll have a pretty solid understanding of cookies. (Okay, that's one too many cookies. Here, have a real one 🍪)

What We'll Learn

Sounds interesting? Let's get started.

What are Cookies?

HTTP is a stateless protocol. The server treats each request as separate and the connection between the server and the browser is lost when the transaction ends.

That means, a request doesn't know anything about the previous request or the ones coming after it. Hence, each request must contain enough information on its own for the server to handle and satisfy it.

This causes a problem for web applications that need to maintain state between multiple requests. For example, if the user has successfully logged in, then the application shouldn't ask them to log in again when the try to access a protected page. The server should remember that they're logged in.

Q: How can you keep this state in a stateless protocol, where each request is considered unique and independent?

A: You use a cookie to store data between multiple requests.

When the user visits a website or a web application, the server can send a small piece of data (token) along with the response. This token is called a cookie.

When the browser receives the response, it checks if there were any cookies that came with it, and saves them in its internal storage if there were. Then, it sends those cookies to for each subsequent request to the same server.

How Cookies Work
How Cookies Work

It's important to remember that the server sets a different cookie for different clients (browsers), even if they're on the same computer. That's why cookies are typically used to tell if multiple requests came from the same browser. This lets the website remember useful information from previous requests (such as the user's unique identifier), despite HTTP being a stateless protocol.

How do you stay logged in on Amazon?

Let's say you're logging in to Amazon for the first time. Once you log in, Amazon's back-end server will send a cookie in the response, and this cookie will be sent to the server every time you browse any pages on Amazon.

Upon receiving the cookie, Amazon's server checks if the user corresponding to that cookie is logged in, and displays a personalized web page.

However, if you browse Amazon from a different device or even a different browser, it won't send the cookie to the server, as it doesn't have that cookie. Cookies are scoped to the browser. Hence you won't be logged in on a different device or browser.

See RFC 6265 for an in-depth overview of cookies.

RFC 6265: HTTP State Management Mechanism
This document defines the HTTP Cookie and Set-Cookie header fields. These header fields can be used by HTTP servers to store state (called cookies) at HTTP user agents, letting the servers maintain a stateful session over the mostly stateless HTTP protocol. Although cookies have many historical infe…

Why Do We Need Cookies?

Cookies are used to maintain state between multiple HTTP requests, i.e. to tell if two HTTP requests came from the same browser. As we just saw, the obvious use case is to remember a logged-in user. However, there're few other use cases of cookies. Some are ethical, some are not...

Cookies are mainly used for three purposes:

  1. Session management: Any application that needs to keep track of logged in users and their information, for example shopping carts, test or game scores, etc. Authentication cookies keep state-related information about the currently logged-in user.
  2. Personalization: Any application that needs to customize and personalize the application, such as the theme, look-and-feel of the site, or preferences for the current user browsing the site. A good example is to save the light or dark theme preference for a user visiting a web page.
  3. Tracking: Any application that needs to record and analyze the user behavior. Tracking cookies are used to build a browsing history for users, which can later be used for targeted ads or sales pitches by other companies.

How to Read and Write Cookies?

At this point, if you've never worked with cookies, you might be itching to know just how the server can set the cookie and read it in the next request. In this section, we'll learn the basics of cookie access and also how Rails does it in an elegant way.

Cookies can come in via request, which means the user had the cookie when they visited the page. Cookies can also be sent out with a response, which means the user's browser will save the cookie for future visits.

When the server receives an HTTP request, it can set one or more cookies to the client with the Set-Cookie response header. Upon receiving the response, the browser notices this header and stores the cookie in its internal storage. For each subsequent request to the same host server, it sends the cookies it received from this server using the Cookie header.

If you want to store multiple cookies in a single response, just use multiple Set-Cookie headers.

Here's an example of an HTTP response that sets a pair of cookies.

HTTP/2.0 200 OK
Content-Type: text/html
Set-Cookie: username=akshay
Set-Cookie: theme=railscasts

[page content]

Then, with every subsequent request to the server, the browser sends all previously stored cookies back to the server using the Cookie header.

GET /sample_page.html HTTP/2.0
Host: www.rubyonrails.org
Cookie: username=akshay; theme=railscasts

Accessing Cookies in Rails

Rails provides a nice cookies hash-like object to work with cookies. Like a regular hash, you can read and write key-value pairs into it. You can access this cookies hash in Rails controllers, views, and helpers.

Behind the scenes, Rails fills it with the cookies received from the request and sends out any cookies you write to it with the response.

To set a cookie (or even update an existing cookie), assign the value, just like you would on a Hash. The value can be either a single string or a hash containing options, which we'll see later.

class CommentsController < ApplicationController
  def new
    @comment = Comment.new(author: cookies[:commenter_name])
  end

  def create
    cookies[:commenter_name] = @comment.author
    
    # or
    
    cookies.delete(:commenter_name)
  end
end

Just like the Hash, to delete a cookie value you'd use cookies.delete(:key).

So far we know that the browser stores the cookie sent by the server in its internal storage. But for how long? Should it store it for 10 minutes, an hour, few days, or eternally?

For this, each cookie has a lifetime after which it expires. The server decides how long the browser should store the cookie. This lifetime is also called a single session during which that client browser (and the user) is active. In most cases, the browser drops the cookie after you quit the browser. To keep it longer for a specific amount of time, you use the Expires attribute of the cookie.

The Expires attribute tells the browser when it should delete the cookie. Alternatively, you can use the Max-Age attribute to specify a period of time for which it should store it.

Set-Cookie: font=monaco; Expires=Thu, 31 Oct 2021 07:28:00 GMT;

The Expires date and time is relative to the client the cookie is being set on, not the server.

In Rails, you can specify the Expires attribute by providing the expires option while setting the cookie. It takes the number of seconds, a timetamp, or a ActiveSupport::Duration object for the value.

# Sets a cookie that expires in 1 hour.
cookies[:theme] = { value: "monokai", expires: 20.minutes.from_now }

# Sets a cookie that expires on the specified time.
cookies[:theme] = { value: "railscasts", expires: Time.utc(2023, 4, 5) }

For the permanent cookies that should never be expired, Rails defines forever period as 20 years.

# Sets a "permanent" cookie (which expires in 20 years from now).
cookies.permanent[:plan] = "basic"

Restrict Cookies to Specific Domains

Once the server sends the cookie to the browser, who can access it? Can the browser sends that cookie to any website that it wants to? Or should it only send it to the server that set the cookie?

You can restrict the scope of a cookie to a specific domain and path to limit where the cookie is sent.

Whenever the browser makes an HTTP request to any website/application, it uses the cookie scope to determine if the cookie should be sent to the server. This scope consists of three attributes: Domain,  Path, and SameSite.

Domain

The Domain attribute specifies which hosts can receive a cookie. By default, it is empty, which tells the browser that the cookie should only be sent to the exact domain (server) which set that cookie. Which makes sense. You don't want to send the Amazon's cookies to GitHub.

An important thing to keep in mind is that even the subdomains can't access the cookies, if you don't set the domain attribute. For that, you have to specify the domain. If you set Domain=mozilla.org, cookies are available on subdomains like developer.mozilla.org.

Alternatively, in Rails, you can explicitly set the Domain option to all. Now the browser will send the cookie to the domain and all its subdomains.

cookies[:product] = { value: "iphone", domain: 'apple.com' }

cookies[:company] = {
  value: 'apple',
  expires: 6.months,
  domain: 'apple.com'
}

# Options
{ domain: nil }  # Does not set cookie domain. (default)
{ domain: :all } # Allow the cookie for the top most level domain and subdomains.
{ domain: %w(.example.com .example.org) } # Allow the cookie for concrete domain names.

Path

The Path attribute indicates a URL path that must exist in the requested URL to send the cookie.

For example, if you set Path=/login, these request paths match: /login, /login/, /login/web, /login/user/name. However, the URLs / and /sign-in are not matched, since they don't contain the path.

In Rails, you can use the :path option to provide specific paths. It defaults to /, i.e. the root of the application, matching all paths.

cookies[:product] = { value: "iphone", path: '/apple' }

SameSite

So far, we've only restricted the cookie scope to the same domain/host it came from. What about cross-site requests?

The SameSite attribute lets server specify whether/when cookies are sent with cross-site requests, providing some protection against cross-site request forgery attacks (CSRF). It takes three values: Strict, Lax, and None.

  • Strict: The browser only sends the cookie to the server/host/website that set the cookie.
  • Lax: Similar to Strict, except the browser also sends the cookie when the user navigates away to the site that set the cookie by following a link from an external site. This is the default. If no SameSite attribute is set, the cookie is treated as Lax.
  • None: Cookies are sent on both originating and cross-site requests, but only in secure contexts, i.e. if SameSite=None then the Secure attribute must also be set (which we'll see in the next section).
Set-Cookie: key=value; SameSite=Strict

Because of its usefulness in protecting against CSRF attacks, Lax has become the standard in browsers. Since Rails 6.1, Rails will set cookies with Lax by default.

# Possible values are :none, :lax, and :strict. 
# Defaults to :lax.
cookies[:user] = { value: "steve_jobs", same_site: :strict }

You can change the default from Lax as follows:

# config/application.rb
config.action_dispatch.cookies_same_site_protection = :strict

How to Read Cookies in JavaScript

The browser JavaScript provides a nice API to access the cookies. You can create new cookies with JavaScript using the Document.cookie property. If the HttpOnly flag isn't set, you can also read existing cookies.

document.cookie = "product=iphone";
document.cookie = "purchased=true";

console.log(document.cookie); // "product=iphone; purchased=true"

Note: Cookies created via JavaScript can't include the HttpOnly flag.

Restricting JavaScript from Accessing Cookies + HTTPS Support

After restricting the cookie to specific sites, the next problem is how to ensure that the cookies are sent securely and aren't accessed by random scripts. For this, the HTTP protocol provides the Secure and HttpOnly attributes.

  • The Secure attribute ensures that the cookie is only sent to the server with an encrypted request over the HTTPS protocol. It's never sent with unsecured HTTP (except on localhost), making man-in-the-middle attacks really hard.
  • The HttpOnly attribute prevents the client-side JavaScript code to read the cookie via Document.cookie API. The cookie will be only sent to the server. This helps mitigate cross-site scripting (XSS) attacks.
Set-Cookie: id=ak_98; Expires=Fri, 22 Oct 2021 07:28:00 GMT; Secure; HttpOnly

In Rails, you can pass the secure and httponly attributes while setting the cookie.

  • :secure - Whether this cookie is only transmitted via HTTPS. Default is false.
  • :httponly - Whether JavaScript can access this cookie. Defaults to false.
cookies[:account_number] = { value: @account.number, secure: true, httponly: true }

Working Rails Demo

Let's test our understanding in a real Rails application. We're going to create a simple app with two endpoints:

  • cookies/create writes a new cookie along with the outgoing HTTP response.
  • cookies/show reads the cookie from the incoming HTTP request and then sends out the cookie value in the response.

Here's the complete code.

# config/routes.rb
Rails.application.routes.draw do
  get 'cookies/create', as: 'set_cookie'
  get 'cookies/show', as: 'get_cookie'
end

# app/controllers/cookies_controller.rb
class CookiesController < ApplicationController
  def create
    cookies["test_cookie"] = "delicious cookie"
  end

  def show
    @cookie_value = cookies["test_cookie"]
  end
end

# app/views/cookies/create.html.erb
<h1>Cookie is set successfully!</h1>

# app/views/cookies/show.html.erb
<h1><%= @cookie_value %></h1>

Let's try this out.

Go ahead and start the application. Then open localhost:3000/cookies/create in your web browser. You should see the Cookie is set successfully! response, along with the Set-Cookie header in the HTTP response.

You can see the cookie in the Application tab.

Setting a Cookie
Setting a Cookie

Next, visit localhost:3000/cookies/show URL, and the browser will pass our cookie along with the HTTP request. On the server, the show action will retrieve the cookie value and print it in the response, as follows:

Reading and Sending a Cookie in Response
Reading and Sending a Cookie in Response

Exercise for the reader: try various options we've seen so far, like httponly, expires, and domain and see how the cookie behavior changes on the client.

How to Test Cookies in Rails?

Here's an integration test that shows how you can verify if the server can set a cookie and also read it.

# test/controllers/cookies_controller_test.rb

require "test_helper"

class CookiesControllerTest < ActionDispatch::IntegrationTest
  test "should set a cookie" do
    get set_cookie_path
    assert_response :success
    assert_equal "delicious cookie", cookies["test_cookie"]
  end

  test "should get the cookie" do
    get get_cookie_path, headers: { "Cookie" => "test_cookie=oreo" }
    assert_response :success
    # assuming the server set the "new_cookie"
    assert_equal "oreo",  @response.cookies["new_cookie"]
  end
end

Note that you have access to the HTTP response via @response. Then you can access the headers with @response.headers and cookies with @response.cookies.

Signed and Encrypted Cookies

Since a cookie is stored in the user's browser, they can both read it and change it. If you don't believe me, open the cookie in the Application tab, and double click on the value. You'll see that you can edit it just fine. The next time the browser visits the site, it will send the modified cookie.

Modifying a Cookie
Modifying a Cookie

This might be alright for some cookies. For others, like a session cookie, this is not as it can compromise the security. What if someone figures out that you're storing usernames in the cookies and changes a cookie value to admin?

To prevent the users from modifying the cookie, that is, read the cookie but not change it, you can use a signed cookie:

# create a signed cookie
cookies.signed[:user_id] = current_user.id

# read a signed cookie
cookies.signed[:user_id]

If the user modifies the cookie, Rails will check the cookie signature and discard the cookie setting it to nil.

However, the user can still read the cookie, even though it's signed. To prevent users from reading the value of the cookies, you have to encrypt it.

# write an encrypted cookie
cookies.encrypted[:user_id] = current_user.id

# read an encrypted cookie
cookies.encrypted[:user_id]
💡
Rails provides a signed cookie jar and an encrypted cookie jar for storing sensitive data. The signed cookie jar appends a cryptographic signature on the cookie values to protect their integrity. The encrypted cookie jar encrypts the values in addition to signing them, so that they cannot be read by the end-user.

You can also chain these methods.

# set a signed and permanent cookie
cookies.signed.permanent[:company] = "37signals"

# read that cookie
cookies.signed[:company]        # => "37signals"

The cookie will first be encrypted and then signed.

Keep in Mind

  • Use the HttpOnly attribute to prevent access to cookie values via JavaScript.
  • Cookies that are used for sensitive information should have a short lifetime, with the Samesite attribute set to Strict or Lax.
  • Be aware that cookies increase the size of each request to your server.
  • Only store simple data (strings and numbers) in cookies. If you have to store complex objects, you would need to handle the conversion manually when reading the values on subsequent requests.

Next Steps: Understanding Rails Sessions

If you're still here and curious to learn more about cookies and their practical applications, I suggest you read the following article, written by yours truly, on sessions in Rails.

Sessions in Rails: Everything You Need to Know
In this post, we’ll learn about Rails sessions, including what is a session, why we need them, and why they’re so important. I’ll also take you behind the scenes and show you how Rails implements sessions and where the `session` method actually comes from. Hint: it’s not in the Rails codebase.

It shows what's a session, why we need them, and why they're so important. We'll also learn how Rails implements sessions and where the session method actually comes from. Hint: it’s not in the Rails codebase.


That's a wrap. I hope you liked this article and you learned something new. If you're new to the blog, check out the start here page for a guided tour or browse the full archive to see all the posts I've written so far.

As always, if you have any questions or feedback, didn't understand something, or found a mistake, please leave a comment below or send me an email. I reply to all emails I get from developers, and I look forward to hearing from you.

If you'd like to receive future articles directly in your email, please subscribe to my blog. If you're already a subscriber, thank you.