Demystifying cookie security in Rails 6

posted by Ayush Newatia
2 November, 2020



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 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.

Inspecting the value of a cookie using a 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!