Have you ever wondered how Rails takes a simple line like this:
cookies.encrypted[:ginger] = "molasses"
and turns it into something like this?
+Y4bdwYlbl9gfodGiSOQRdvOaqA6chOiLLN+dVUFYrbeWy1vbdSYmzU=--vmDGNyxh+Ls=--vzFT2pGsjjs192GBaaO49A==
Let's walk through it in this interactive playground!
First, let's choose a cookie. You can put whatever you want in these text fields, and the rest of the page will change accordingly.
cookies.encrypted[] = ""
๐ Wrap
OK! First, Rails will package that up into a cookie payload. That's just a Hash object with the cookie value and some metadata for security.
Are you using cookies with metadata? (It's the default since Rails 6.0.)
Rails.application.config.action_dispatch.use_cookies_with_metadata =
All right, here's the payload:
When reading cookies, pur, which stands for
"purpose", is cross-checked with the cookie's
name. That way, an attacker can't
copy the value
of one cookie to one with a different name.
๐ Serialize
That payload is a Ruby Hash, but encryption works on sequences of bytes. So we need to turn the hash into bytes. This is called serialization.
A string is a sequence of bytes, and JSON is a string format that can represent a Ruby Hash... and conveniently, JSON is already the default cookie serializer as of Rails 7.0. So let's use that!
Now we have a sequence of bytes. But to encrypt it, we still need an encryption key.
๐ Make an encryption key
The encryption key has to be secret. ๐คซ Your Rails application already has a secret key base, so we can use that to encrypt the JSON from above.
You did set a secret key base, right?
ENV["SECRET_KEY_BASE"] = ""
For a variety of reasons, we shouldn't use the secret key base directly as an encryption key, but we can still derive one from it.
Rails will use PBKDF2 to do this. PBKDF2 will essentially hash your secret key base with a salt a lot (like 1,000 times).
Normally, you don't have to choose a salt.
By default,
it's "authenticated encrypted cookie". And you don't normally need to choose the hash function; it's SHA-256 by default.
Feel free to play around with the config below!
Rails.application.configure do |config|
config.action_dispatch.authenticated_encrypted_cookie_salt
= ""
config.active_support.key_generator_hash_digest_class =
OpenSSL::Digest::
end
Lastly, the number of bytes we need our new key to have depends on the exact encryption cipher we're going to use to encrypt the cookie.
AES-GCM is a good cipher. Let's use that. How many bits long do you want your key to be?
(The default is 256, so I'd stick with that unless you have a
reason not to.)
Rails.application.config.action_dispatch.encrypted_cookie_cipher =
"aes--gcm"
Mix it all together, and... tada! ๐ Here's the secret key!
(It's bits. I
Base64-encoded it for your human eyes.)
...
๐ Encrypt!
Now that we have an encryption key, we're almost ready to encrypt the payload. All we need is a randomly-generated initialization vector, which helps us thwart decryption attacks and lets us decrypt the data on the other end:
...
And presto! Here's the encrypted payload โ that JSON blob from before! ๐
...
AES-GCM also outputs an "authentication tag," which prevents tampering. You can think of it kind of like a built-in signature. Here's the authentication tag:
...
Now, we have:
- The ciphertext, which is the encrypted payload containing the cookie's value and some other stuff;
- The initialization vector, which is required for decryption;
- The authentication tag, which ensures that nobody's tampered with the ciphertext.
Finally, we concatenate these three together with -- as a
separator, which allows us to
extract the pieces again for decryption. And voila! We have
the finished cookie! Rails will send it to the browser as a header, like so:
...--...--...
Whew! Encrypted cookies are complex. We learned about a whole host of (mostly configurable!) inputs that go into “baking” a single encrypted cookie. Here are the ones you chose:
ENV["SECRET_KEY_BASE"] = ""
Rails.application.configure do |config|
config.action_dispatch.use_cookies_with_metadata =
config.action_dispatch.authenticated_encrypted_cookie_salt
= ""
config.active_support.key_generator_hash_digest_class =
OpenSSL::Digest::
config.action_dispatch.encrypted_cookie_cipher =
"aes--gcm"
end
All together, Rails used those to turn your one cookie line:
cookie[] =
:
into this HTTP response header:
Set-Cookie: =...--...--...
If you want to dive even deeper, the EncryptedKeyRotatingCookieJar source code is where this journey started for me.
Happy baking! ๐งโ๐ณ