Demystifying cookie security in Rails 6
Tags: rails, ruby, security, webdev
Published: 2021-01-18 15:18:44

*This blog post was originally published on [my blog](https://binarysolo.chapter24.blog/demystifying-cookies-in-rails-6/).*

*I've also given a talk based on this post. [Check it out here](https://www.youtube.com/watch?v=To_nVr7cwCg)!*



[Cookies](https://en.wikipedia.org/wiki/HTTP_cookie) 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](https://api.rubyonrails.org/classes/ActionDispatch/Cookies.html) 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.&nbsp;

  

- **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:  
  

```ruby
def show
  cookies[:welcome_message_shown] = "true"
end
```

  
This line will add a `Set-Cookie` [HTTP header](https://en.wikipedia.org/wiki/List_of_HTTP_header_fields) 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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/neaoqw5a0iexbeofmgcu.png)

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`](https://api.rubyonrails.org/classes/ActiveSupport/MessageVerifier.html) 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](https://signalvnoise.com/posts/3112-how-basecamp-next-got-to-be-so-damn-fast-without-using-much-client-side-ui).  
  
Storing a signed cookie is as easy as storing a plain text cookie:  
  

```ruby
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](https://en.wikipedia.org/wiki/Base64) 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:  
  

```ruby
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:  
  

```ruby
{ 
  "_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`](https://api.rubyonrails.org/classes/ActiveSupport/MessageVerifier.html) during decoding and validation.  
  
The `message` is also a _Base64 encoded JSON object_ so we decode it the same way as above:  
  

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

```ruby
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!  
  

```js
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](https://github.com/rails/rails/blob/fe76a95b0d252a2d7c25e69498b720c96b243ea2/activesupport/lib/active_support/message_verifier.rb#L200) with the [`SHA1`](https://en.wikipedia.org/wiki/SHA-1) 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](https://github.com/rails/rails/blob/fe76a95b0d252a2d7c25e69498b720c96b243ea2/activesupport/lib/active_support/key_generator.rb#L22) and is [based on the `secret_key_base`](https://github.com/rails/rails/blob/fe76a95b0d252a2d7c25e69498b720c96b243ea2/railties/lib/rails/application.rb#L173) that you find in your `credentials.yml` file and [another string](https://github.com/rails/rails/blob/fe76a95b0d252a2d7c25e69498b720c96b243ea2/actionpack/lib/action_dispatch/middleware/cookies.rb#L571) called a [_salt_](https://en.wikipedia.org/wiki/Salt_(cryptography)). 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:  
  

```ruby
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`](https://api.rubyonrails.org/classes/ActiveSupport/KeyGenerator.html) and [`ActiveSupport::MessageVerifier`](https://api.rubyonrails.org/classes/ActiveSupport/MessageVerifier.html) 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`](https://api.rubyonrails.org/v6.0.3.2/classes/ActiveSupport/MessageEncryptor.html) (which uses OpenSSL under the hood).&nbsp;  
  
Let's create an encrypted cookie and see what it looks like:  
  

```ruby
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](https://en.wikipedia.org/wiki/Initialization_vector), which is a random input to the encryption algorithm. And the third part is an [authentication tag](https://crypto.stackexchange.com/questions/67107/what-is-the-purpose-of-an-authentication-tag-in-aead-encryption-schemes), which is similar to the _digest_ of a signed cookie. All three parts are **Base64 encoded**.  
  
By default, cookies are encrypted with [AES](https://en.wikipedia.org/wiki/Advanced_Encryption_Standard) using a 256-bit key in [Galois/Counter Mode](https://en.wikipedia.org/wiki/Galois/Counter_Mode) (`aes-256-gcm`). This can be changed by setting `config.action_dispatch.encrypted_cookie_cipher` to any valid [`OpenSSL::Cipher`](https://ruby-doc.org/stdlib-2.7.1/libdoc/openssl/rdoc/OpenSSL/Cipher.html) algorithm.  
  

**Decrypting encrypted cookies**  
  
The [cookie is encrypted](https://github.com/rails/rails/blob/fe76a95b0d252a2d7c25e69498b720c96b243ea2/activesupport/lib/active_support/message_encryptor.rb#L166) with a [key that's generated](https://github.com/rails/rails/blob/fe76a95b0d252a2d7c25e69498b720c96b243ea2/actionpack/lib/action_dispatch/middleware/cookies.rb#L602) 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](https://github.com/rails/rails/blob/fe76a95b0d252a2d7c25e69498b720c96b243ea2/activesupport/lib/active_support/message_encryptor.rb#L183), we can decrypt the cookie as follows:  
  

```ruby
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.&nbsp;  
  
Cookies can be made to persist between sessions by specifying an expiry date:  
  

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

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

```ruby
def show
  cookies.signed.permanent[:user_id] = "42"
end
```

  

```ruby
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`](https://api.rubyonrails.org/classes/ActionDispatch/Flash.html) data in the session cookie.  
  
Data can be stored in the session cookie similarly to regular cookies:  
  

```ruby
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.&nbsp;  
  
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!