Cookies on Rails

Neeraj Singh

Neeraj Singh

March 19, 2013

Let's see how session data is handled in Rails 3.2 .

If you generate a Rails application in 3.2 then ,by default, you will see a file at config/initializers/session_store.rb. The contents of this file is something like this.

1Demo::Application.config.session_store :cookie_store, key: '_demo_session'

As we can see _demo_session is used as the key to store cookie data.

A single site can have cookies under different key. For example airbnb is using 14 different keys to store cookie data.

airbnb cookies

Session information

Now let's see how Rails 3.2.13 stores session information.

In 3.2.13 version of Rails application I added following line to create session data.

1session[:github_username] = 'neerajdotname'

Then I visit the action that executes above code. Now if I go and look for cookies for localhost:3000 then this is what I see .

demo session

As you can see I have only one cookie with key _demo_session .

The cookie has following data.

1BAh7CEkiD3Nlc3Npb25faWQGOgZFRkkiJTgwZGFiNzhiYWZmYTc3NjU1ZmVmMGUxM2EzYmEyMDhhBjsAVEkiFGdpdGh1Yl91c2V
2ybmFtZQY7AEZJIhJuZWVyYWpkb3RuYW1lBjsARkkiEF9jc3JmX3Rva2VuBjsARkkiMU1KTCs2dXVnRFo2R2NTdG5Kb3E2dm5Bcl
3ZYRGJGbjJ1TXZEU0swamxyWU09BjsARg%3D%3D--b5bcce534ceab56616d4a215246e9eb1fc9984a4

Let's open rails console and try to decipher this information.

1content = 'BAh7CEkiD3Nlc3Npb25faWQGOgZFRkkiJTgwZGFiNzhiYWZmYTc3NjU1ZmVmMGUxM2EzYmEyMDhhBjsAVEkiFGdpdGh1Yl91c2V
2ybmFtZQY7AEZJIhJuZWVyYWpkb3RuYW1lBjsARkkiEF9jc3JmX3Rva2VuBjsARkkiMU1KTCs2dXVnRFo2R2NTdG5Kb3E2dm5BclZYRGJGbjJ1T
3XZEU0swamxyWU09BjsARg%3D%3D--b5bcce534ceab56616d4a215246e9eb1fc9984a4'

When the content is written to cookie then it is escaped. So first we need to unescape it.

1> unescaped_content = URI.unescape(content)
2=> "BAh7CEkiD3Nlc3Npb25faWQGOgZFRkkiJTgwZGFiNzhiYWZmYTc3NjU1ZmVmMGUxM2EzYmEyMDhhBjsAVEkiFGdpdGh1Yl91c2V
3ybmFtZQY7AEZJIhJuZWVyYWpkb3RuYW1lBjsARkkiEF9jc3JmX3Rva2VuBjsARkkiMU1KTCs2dXVnRFo2R2NTdG5Kb3E2dm5BclZYRG
4JGbjJ1TXZEU0swamxyWU09BjsARg==--b5bcce534ceab56616d4a215246e9eb1fc9984a4"

Notice that towards the end unescaped_content has -- . That is a separation marker. The value before -- is the real payload. The value after -- is digest of data.

1> data, digest = unescaped_content.split('--')
2=> ["BAh7CEkiD3Nlc3Npb25faWQGOgZFRkkiJTgwZGFiNzhiYWZmYTc3NjU1ZmVmMGUxM2EzYmEyMDhhBjsAVEkiFGdpdGh1Yl91c2V
3ybmFtZQY7AEZJIhJuZWVyYWpkb3RuYW1lBjsARkkiEF9jc3JmX3Rva2VuBjsARkkiMU1KTCs2dXVnRFo2R2NTdG5Kb3E2dm5BclZYRGJ
4GbjJ1TXZEU0swamxyWU09BjsARg==", "b5bcce534ceab56616d4a215246e9eb1fc9984a4"]

The data is Base64 encoded. So let's unecode it.

1> Marshal.load(::Base64.decode64(data))
2=> {"session_id"=>"80dab78baffa77655fef0e13a3ba208a",
3    "github_username"=>"neerajdotname",
4    "_csrf_token"=>"MJL+6uugDZ6GcStnJoq6vnArVXDbFn2uMvDSK0jlrYM="}

So we are able to get the data that is stored in cookie. However we can't tamper with the cookie because if we change the cookie data then the digest will not match.

Now let's see how Rails matches the digest.

In order to create the digest Rails makes of use of config/initializer/secret_token.rb . In my case the file has following content.

1Demo::Application.config.secret_token = '111111111111111111111111111111'

This secret token is used to create the digest.

1> secret_token =  '111111111111111111111111111111'
2> OpenSSL::HMAC.hexdigest(OpenSSL::Digest.const_get('SHA1').new, secret_token, data)
3=> "b5bcce534ceab56616d4a215246e9eb1fc9984a4"

Notice that the result of above produces a value that is same as digest in earlier step. So if cookie data is tampered with then the digest match will fail. This is why it is absolute necessary that attacker should not be able to get access to secret_token value.

Did you notice that we can access the cookie data without needing secret_token. It means the data stored in cookie is not encrypted and anyone can see it. That is why it is recommended that application should not store any sensitive information in cookie .

In the previous example we used session to store and retrieve data from cookie. We can directly use cookie and that gives us a little bit more control.

1cookies[:github_username] = 'neerajdotname'

Now if we look at cookie stored in browser then this is what we see.

update cookie

As you can see now we have two keys in our cookie. One created by session and the other one created by code written above.

Another thing to note is that the data stored for key github_username is not Base64encoded and it also does not have -- to separate the data from the digest. It means this type of cookie data can be tampered with by the user and the Rails application will not be able to detect that the data has been tampered with.

Now let's try to sign the cookie data to make it tamper proof.

1cookies.signed[:twitter_username] = 'neerajdotname'

Now let's look at cookies in browser.

update cookies

This time we got data with another key twitter_username . Another thing to notice is that cookie data is signed and is tamper proof.

When we use session then behind the scene it uses cookies.signed. That's why we end up seeing signed data for key _demo_session .

What happens when user tampers with signed cookie data.

Rails does not raise any exception. However when you try to access cookie data then nil is returned because the data has been tampered with.

Security should be on by default

session , by default, uses signed cookies which prevents any kind of tampering of data but the data is still visible to users. It means we can't store sensitive information in session.

It would be nice if the session data is stored in encrypted format. And that's the topic of our next discussion.

Rails 4 stores session data in encrypted format

If you generate a Rails application in Rails 4 then ,by default, you will see a file at config/initializers/session_store.rb . The contents of this file is something like

1Demo::Application.config.session_store :cookie_store, key: '_demo_session'

Also you will notice that file at config/initializers/secret_token.rb looks like this .

1Demo::Application.config.secret_key_base = 'b14e9b5b720f84fe02307ed16bc1a32ce6f089e10f7948422ccf3349d8ab586869c11958c70f46ab4cfd51f0d41043b7b249a74df7d53c7375d50f187750a0f5'

Notice that in Rails 3.2.x the key was secret_token. Now the key is secret_key_base .

1session[:github_username] = 'neerajdotname'

cookies and site data

Cookie has following data.

1RkxNUWo4NlBKakoyU1VqZWJIKzNaV0lQVVJwQjZhdUVTRnowVHppSVJ3Mk84TStoS1hndFZFNHlNaGw2RHBCc0ZiaEpsM0NtYTg4d
2nptcjFaQWVJbUdOaFh5MVlCdWVmSHBMNWpKbkRKR0JrSU5KZFYwVjVyWTZ3aUNqSWxJM1RTMkQybEtPUFE5VDFsZVJyakx0dFh3PT
30tLTZ5NGIreU00Z0MyNnErS29SSGEyZkE9PQ%3D%3D--3f2fd67e4e7785933485a583720d29ba88bca15f

Let's open rails console and try to decipher this information.

1content = 'RkxNUWo4NlBKakoyU1VqZWJIKzNaV0lQVVJwQjZhdUVTRnowVHppSVJ3Mk84TStoS1hndFZFNHlNaGw2RHBCc0ZiaEpsM0NtYTg4d
2nptcjFaQWVJbUdOaFh5MVlCdWVmSHBMNWpKbkRKR0JrSU5KZFYwVjVyWTZ3aUNqSWxJM1RTMkQybEtPUFE5VDFsZVJyakx0dFh3PT
30tLTZ5NGIreU00Z0MyNnErS29SSGEyZkE9PQ%3D%3D--3f2fd67e4e7785933485a583720d29ba88bca15f'

When the content is written to cookie then it is escaped. So first we need to unescape it.

1unescaped_content = URI.unescape(content)
2=> "RkxNUWo4NlBKakoyU1VqZWJIKzNaV0lQVVJwQjZhdUVTRnowVHppSVJ3Mk84TStoS1hndFZFNHlNaGw2RHBCc0ZiaEpsM0NtYTg4d
3nptcjFaQWVJbUdOaFh5MVlCdWVmSHBMNWpKbkRKR0JrSU5KZFYwVjVyWTZ3aUNqSWxJM1RTMkQybEtPUFE5VDFsZVJyakx0dFh3PT 0tLTZ
45NGIreU00Z0MyNnErS29SSGEyZkE9PQ==--3f2fd67e4e7785933485a583720d29ba88bca15f"

Now we need secret_key_base value. And using that let's build key_generator .

1secret_key_base = 'b14e9b5b720f84fe02307ed16bc1a32ce6f089e10f7948422ccf3349d8ab586869c11958c70f46ab4cfd51f0d41043b7b249a74df7d53c7375d50f187750a0f5'
2key_generator = ActiveSupport::KeyGenerator.new(secret_key_base, iterations: 1000)
3key_generator = ActiveSupport::CachingKeyGenerator.new(key_generator)

Our MessageEncryptior needs two long random strings for encryption. So let's generate two keys for encryptor.

1secret = key_generator.generate_key('encrypted cookie')
2sign_secret = key_generator.generate_key('signed encrypted cookie')
3encryptor = ActiveSupport::MessageEncryptor.new(secret, sign_secret)

Now we can finally decipher the data.

1data =  encryptor.decrypt_and_verify(unescaped_content)
2puts data
3=> neerajdotname

As you can see we need the secret_key_base to make sense out of cookie data. So in Rails 4 the session data will be encrypted ,by default.

Rails4 will transparently will upgrade cookies from unencrypted to encrypted cookies. This is a brilliant example of trivial choices removed by Rails.

If this blog was helpful, check out our full blog archive.

Stay up to date with our blogs.

Subscribe to receive email notifications for new blog posts.