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
- What are cookies?
- Why do we need cookies?
- How to read and write cookies?
- Accessing cookies in Rails
- Lifetime of a cookie
- Restrict cookies to specific domains
- How to read cookies in JavaScript
- Restricting JavaScript from accessing cookies + HTTPS support
- Working Rails Demo
- How to test cookies in Rails?
- Signed and Encrypted cookies
- Next Steps: Understanding Rails Sessions
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.
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.
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:
- 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.
- 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.
- 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)
.
Lifetime of a Cookie
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 toStrict
, 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 noSameSite
attribute is set, the cookie is treated asLax
.None
: Cookies are sent on both originating and cross-site requests, but only in secure contexts, i.e. ifSameSite=None
then theSecure
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 viaDocument.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 isfalse
.:httponly
- Whether JavaScript can access this cookie. Defaults tofalse
.
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.
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:
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
assert_equal "oreo", @response.cookies["test_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.
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]
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 toStrict
orLax
. - 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.
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.