This blog post was originally published on my blog.
I've also given a talk based on this post. Check it out here!
Cookies are used in pretty much every modern web application. They're used for various purposes such as facilitating user authentication and storing user preferences. Since they're so widely used it's no surprise that a full-stack development framework like Rails has a simple and convenient API to manage them.
In this post I'll describe the different types of cookies supported by Ruby on Rails and how they work under the hood.
Types of Cookies in Rails
Rails supports the storage of 3 kinds of cookies:
Plain text : These cookies can be viewed and changed by a user.
Signed : Signed cookies look like gibberish but they can easily be decoded by a user although they can't be modified as they are cryptographically signed.
Encrypted : Encrypted cookies can't be decoded by a user (not easily, anyway) and nor can they be modified as they are authenticated at the time of decryption.
Plain Text Cookies
Plain text cookies should be used very cautiously and sparingly. They can be viewed and changed to any value by a user without our application ever knowing. A good use case for a plain text cookie would be to store whether or not a welcome message has been shown to the user.
You can set such a cookie with a single line of code in a controller action:
def show
cookies[:welcome_message_shown] = "true"
end
This line will add a Set-Cookie
HTTP header to the response; with the value welcome_message_shown=true
. When the browser receives the response, it will store the cookie and send it as a header with every subsequent request. You can view the cookie under the Storage tab of your browser's developer tools.
The value of the cookie can be changed by double-clicking and modifying the value field. In this case it doesn't matter as the worst case is the user should be shown a welcome message again. For any sensitive information, signed or encrypted cookies should be used.
Signed Cookies
Signed cookies are designed to store information that is harmless for a user to view but not modify. Values such as a user id or the user's preferences are ideal candidates for signed cookies.
The value of a signed cookie is serialized along with some metadata before being encoded and signed. The default serializer is JSON
but this can be changed in the cookies_serializer.rb
file under the config/initializers
directory.
Under the hood, Rails uses the ActiveSupport::MessageVerifier
API to encode and sign the cookie data.
These cookies can also be read in JavaScript (as demonstrated later) so they're a great way to send user specific data from your database to your JavaScript application.
Storing a signed cookie is as easy as storing a plain text cookie:
def show
cookies.signed[:user_id] = "42"
end
This results in a cookie that looks like gibberish to the naked eye.
"eyJfcmFpbHMiOnsibWVzc2FnZSI6IklqUXlJZz09IiwiZXhwIjpudWxsLCJwdXIiOiJjb29raWUudXNlcl9pZCJ9fQ%3D%3D--94afbf4575daf37313f40d6342a994a5e1719d79"
There's two parts to this string and they're separated by the --
. The first part is a Base64 encoded JSON object containing the value we stored and the second part is a cryptographically generated digest. When Rails receives a signed cookie, it compares the value to the digest and if they don't match, the cookie's value will be nil
'd. That's why a user cannot modify a signed cookie.
Decoding signed cookies
A signed cookie can be decoded with the following Ruby code:
cookie = "eyJfcmFpbHMiOnsibWVzc2FnZSI6IklqUXlJZz09IiwiZXhwIjpudWxsLCJwdXIiOiJjb29raWUudXNlcl9pZCJ9fQ%3D%3D--94afbf4575daf37313f40d6342a994a5e1719d79"
cookie_value = cookie.split("--").first
cookie_value = URI.unescape(cookie_value)
cookie_payload = JSON.parse Base64.decode64(cookie_value)
The above code extracts the Base64 encoded JSON object by splitting the cookie value on the --
. It then unescapes the value, decodes it and parses it into a Hash
that looks like:
{
"_rails"=> {
"message"=>"IjQyIg==",
"exp"=>nil,
"pur"=>"cookie.user_id"
}
}
The only attribute that's relevant here is message
. exp
(expiry) and pur
(purpose) are values used by ActiveSupport::MessageVerifier
during decoding and validation.
The message
is also a Base64 encoded JSON object so we decode it the same way as above:
decoded_stored_value = Base64.decode64 cookie_payload["_rails"]["message"]
stored_value = JSON.parse decoded_stored_value
# => "42"
Since the message
is stored as a Base64 encoded JSON object, we can store any JSON serializable object in a signed cookie; it doesn't have to be a string. However to store other kinds of objects, it needs to be placed in a Hash
with the key value
.
def show
cookies.signed[:preferences] = {
value: {
use_dark_mode: true
}
}
end
Decoding signed cookies using JavaScript
The above Ruby code to decode a signed cookie can be translated into JavaScript very easily. So if you need use information stored in signed cookies on the client, you can!
let cookie = "eyJfcmFpbHMiOnsibWVzc2FnZSI6IklqUXlJZz09IiwiZXhwIjpudWxsLCJwdXIiOiJjb29raWUudXNlcl9pZCJ9fQ%3D%3D--94afbf4575daf37313f40d6342a994a5e1719d79"
let cookie_value = unescape(cookie.split("--")[0])
let cookie_payload = JSON.parse(atob(cookie_value))
let decoded_stored_value = atob(cookie_payload._rails.message)
let stored_value = JSON.parse(decoded_stored_value)
console.log(stored_value)
// => "42"
How the digest is computed
The second half of a signed cookie is the digest which is used to verify its validity. It's calculated using OpenSSL with the SHA1
hash function as the default. The hash function can be changed by setting config.action_dispatch.signed_cookie_digest
in your application.rb
.
The hash function requires a secret
in addition to the data to be hashed. The secret
is also calculated using OpenSSL and is based on the secret_key_base
that you find in your credentials.yml
file and another string called a salt. By default the salt is "signed cookie", but it can be changed by setting config.action_dispatch.signed_cookie_salt
.
Following the same methods as used in the Rails source code, we can calculate the digest with the following code:
cookie = "eyJfcmFpbHMiOnsibWVzc2FnZSI6IklqUXlJZz09IiwiZXhwIjpudWxsLCJwdXIiOiJjb29raWUudXNlcl9pZCJ9fQ%3D%3D--94afbf4575daf37313f40d6342a994a5e1719d79"
cookie_value = URI.unescape(cookie.split("--").first)
secret = Rails.application.secret_key_base
key = OpenSSL::PKCS5.pbkdf2_hmac_sha1(secret, "signed cookie", 1000, 64)
digest = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.const_get("SHA1").new, key, cookie_value)
# => "94afbf4575daf37313f40d6342a994a5e1719d79"
digest == cookie.split("--").second
# => true
As you can see, the digest calculated using OpenSSL matches the digest part of the cookie. So if an attacker tried to modify the data in the cookie, the digest would no longer match and Rails would nil
the content of the cookie. The only way an attacker could calculate a valid digest is if they knew the secret_key_base
and salt; which is why it's critical to keep these values safe.
In practice, Rails uses ActiveSupport::KeyGenerator
and ActiveSupport::MessageVerifier
to abstract away the OpenSSL functions. However I used OpenSSL directly in the demo above for clarity. Those encryption functions can be used in any programming language to encode and decode Rails cookies; so if you have services in your infrastructure that aren't written using Rails, you can still use the data in Rails cookies quite easily.
Encrypted Cookies
Any sensitive data stored in cookies should ALWAYS be encrypted. A remember_token
is often used by applications to keep a user logged in even if they close the browser. This information is as sensitive as a user's password so it's a great example of the kind of thing that should be stored in an encrypted cookie.
Encrypted cookies are serialized in the same way as signed cookies and they're encrypted using ActiveSupport::MessageEncryptor
(which uses OpenSSL under the hood).
Let's create an encrypted cookie and see what it looks like:
def show
cookies.encrypted[:remember_token] = "token"
end
This sets a cookie that looks like:
"aDkxgmW4kaxoXBGnjxAaBY7D47WUOveFdeai5kk2hHlYVqDo7xtzZJup5euTdH5ja5iOt37MMS4SVXQT5RteaZjvpdlA%2FLQi7IYSPZLz--2A6LCUu%2F5AsLfSez--QD%2FwiA2t8QQrKk6rrROlPQ%3D%3D"
As seen above, an encrypted cookie is divided into 3 parts separated by --
, rather than two parts like a signed cookie. The first part is the encrypted data. The second part is called an initialization vector, which is a random input to the encryption algorithm. And the third part is an authentication tag, which is similar to the digest of a signed cookie. All three parts are Base64 encoded.
By default, cookies are encrypted with AES using a 256-bit key in Galois/Counter Mode (aes-256-gcm
). This can be changed by setting config.action_dispatch.encrypted_cookie_cipher
to any valid OpenSSL::Cipher
algorithm.
Decrypting encrypted cookies
The cookie is encrypted with a key that's generated in the same way as the key used to calculate the digest of a signed cookie. So we'll need the application's secret_key_base
to be able to decrypt the cookie. By default, the salt is "authenticated encrypted cookie" but it can be changed by setting config.action_dispatch.authenticated_encrypted_cookie_salt
.
Using the Rails source code as a reference, we can decrypt the cookie as follows:
cookie = "aDkxgmW4kaxoXBGnjxAaBY7D47WUOveFdeai5kk2hHlYVqDo7xtzZJup5euTdH5ja5iOt37MMS4SVXQT5RteaZjvpdlA%2FLQi7IYSPZLz--2A6LCUu%2F5AsLfSez--QD%2FwiA2t8QQrKk6rrROlPQ%3D%3D"
cookie = URI.unescape(cookie)
data, iv, auth_tag = cookie.split("--").map do |v|
Base64.strict_decode64(v)
end
cipher = OpenSSL::Cipher.new("aes-256-gcm")
# Compute the encryption key
secret_key_base = Rails.application.secret_key_base
secret = OpenSSL::PKCS5.pbkdf2_hmac_sha1(secret_key_base, "authenticated encrypted cookie", 1000, cipher.key_len)
# Setup cipher for decryption and add inputs
cipher.decrypt
cipher.key = secret
cipher.iv = iv
cipher.auth_tag = auth_tag
cipher.auth_data = ""
# Perform decryption
cookie_payload = cipher.update(data)
cookie_payload << cipher.final
cookie_payload = JSON.parse cookie_payload
# => {"_rails"=>{"message"=>"InRva2VuIg==", "exp"=>nil, "pur"=>"cookie.remember_token"}}
# Decode Base64 encoded stored data
decoded_stored_value = Base64.decode64 cookie_payload["_rails"]["message"]
stored_value = JSON.parse decoded_stored_value
# => "token"
The above code should be pretty self-explanatory in demonstrating how OpenSSL is used to decrypt a cookie. Since the secret_key_base
is required to decrypt a cookie and that is a highly sensitive piece of information, it should NEVER be sent to the client and hence encrypted cookies should never be decrypted in your JavaScript application.
Lifetime of a Cookie
By default, a cookie expires with the browser's "session". That means that when the user closes the browser, all cookies with an expiry date of Session
will be deleted.
Cookies can be made to persist between sessions by specifying an expiry date:
def show
cookies[:welcome_message_shown] = {
value: "true",
expires: 7.days
}
end
Rails also has a special permanent cookie type which sets the expiry date for 20 years in the future.
def show
cookies.permanent[:welcome_message_shown] = "true"
end
Signed and encrypted cookies can be chained with the permanent type to persist them across browser sessions.
def show
cookies.signed.permanent[:user_id] = "42"
end
def show
cookies.encrypted.permanent[:remember_token] = "token"
end
The special session cookie
Rails provides a special kind of cookie called a session cookie which, as the name suggests has an expiry of Session. This is an encrypted cookie and stores the user's data in a Hash
. It's a great place to store things like authentication tokens and redirect locations. Rails stores Flash
data in the session cookie.
Data can be stored in the session cookie similarly to regular cookies:
def create
session[:auth_token] = "token"
end
Conclusion
I hope this post gave you a good understanding of cookies and also the MessageVerifier
and MessageEncryptor
APIs which have some great applications of their own outside of cookies.
I'm not a cryptography expert and everything in this post was gleaned from looking at the Rails source code. So if something's unclear or I've got something wrong; write a comment and let me know!
Top comments (4)
It's great article, the methods also works perfect in Rails 7.0.2
Awesome article
URL.unescape is giving no method error
Thanks! ... ah yes I think it's been deprecated/removed in newer versions of Ruby .... Try
CGI.unescape
... I'll update the article as well.