We build Web & Mobile Applications.

< All Articles

A tamper-proof cookie jar for Rails

When the default session store for Rails was changed to use cookies last year it caused quite a furore: “It’s not secret! It’s not safe!” The truth is it was never meant to be: in most circumstances you shouldn’t really be storing data in cookies that need to be protected so strongly. But I don’t really want to re-open that can of worms! Instead I want to look at a different cookie-related situation that arose during some optimisation work I’ve been doing on an existing Rails application.

The scenario is straightforward enough: the application stores a numeric record ID in a persistent cookie that, while perfectly safe to be seen by users, shouldn’t be changed by them. The solution currently used in the application is equally straightforward: the data is encrypted (using the EzCryto gem and AES encryption) before being written to the cookie, and then decrypted when the data is read back in.

In situations where cookie contents really shouldn’t be seen by users then encryption is the way to go (unless of course you can find a way of not using a cookie at all), however for a simple numeric ID where tampering is the only thing we need to protect against then it seems like overkill. A better solution is to take inspiration from the Rails cookie session store and use a HMAC.

Enter the TamperProofCookieJar

Here’s the code (if it seems familiar it’s because it borrows heavily from the Rails cookie session store):

require 'openssl'

module ActionController
  class TamperProofCookieJar < ActionController::CookieJar

    class TamperedWithCookie < StandardError;
      attr_reader :cookie_name
      def initialize(cookie_name)
        @cookie_name = cookie_name.to_s
      end
    end

    def initialize(controller, secret, digest = 'SHA1')
      super(controller)
      @secret = secret
      @digest = OpenSSL::Digest::Digest.new(digest)
    end

    def [](name, protect_from_forgery = false)
      if (value = super(name)) && protect_from_forgery
        value, hmac = value.to_s.split('--')
        unless hmac == generate_digest(value) || hmac == generate_digest(value = CGI.unescape(value))
          raise TamperedWithCookie.new(name)
        end
      end

      value
    end

    def []=(name, options)
      if options.is_a?(Hash)
        options = options.stringify_keys
        options['name'] = name.to_s
        options['value'] = options['protect_from_forgery'] == true ? "#{options['value']}--#{generate_digest(options['value'])}" : options['value'].to_s
      else
        options = { "name" => name.to_s, "value" => options.to_s }
      end
      set_cookie(options)
    end

    protected

      def generate_digest(value)
        OpenSSL::HMAC.hexdigest(@digest, @secret, value.to_s)
      end

  end
end

The code makes use of the Ruby OpenSSL library to generate a HMAC using the SHA-1 hashing algorithm. A few notes about the code:

Using the TamperProofCookieJar is easy:

# Write a regular cookie (by default HMAC protection is NOT used - so this is just like a regular Rails cookie)
cookies[:test_unprotected] = { :value => user.id, :expires => Time.zone.now + 30.days }

# Write a HMAC protected cookie
# user.id = 100
cookies[:test_protected] = { :value => user.id, :protect_from_forgery => true, :expires => Time.zone.now + 30.days }

# Read a regular cookie (again no protection is the default)
user_id = cookies[:test_unprotected]
=> "100"

# Read a HMAC protected cookie (the boolean parameter turns on HMAC protection)
# Raises TamperProofCookieJar::TamperedWithCookie if the cookie appears to have been changed
user_id = cookies[:test_protected, true]
=> "100"

# Read a raw HMAC protected cookie
# This returns the actual contents of the cookie, including the HMAC, and does not perform tamper checking
user_id = cookies[:test_protected]
=> "100--2157acbbe09b9f412cee3b97ad2e2c2136d3f5e1"

Replacing the Rails CookieJar

If you want to replace the Rails CookieJar with the TamperProofCookieJar in your application you can override the cookies method in your ApplicationController like so:

class ApplicationController < ActionController::Base
  def cookies
    ActionController::TamperProofCookieJar.new(self, 'this should be a long secret key')
  end
end

The second parameter should be a secret key that is hard to guess, just like the one used by the Rails cookie session store.

Performance improvements

As I said earlier, the TamperProofCookieJar was created as part of an optimisation project, so how does its performance compare with the original encryption solution? Using a simple benchmark on 20,000 encryptions versus 20,000 HMACs gives the following results:

Encryption total time
2.918 seconds
HMAC total time
0.854 seconds

This gives a 3.4x performance increase which on a high volume site such as the one I’m working on is a worthy saving!

To patch or plugin?

My original intention was to submit this as a patch to Rails however I don’t think it will be seen as “useful” enough for the wider Rails community to be included in core. Instead I’m leaning towards making it a plugin: so please let me know if you have any problems with the code or have any suggestions for how it can be improved and when time allows I’ll put it all together.

Updated on 07 February 2019
First published by Rob Anderton on 14 July 2008
© Rob Anderton 2019
"A tamper-proof cookie jar for Rails" by Rob Anderton at TheWebFellas is licensed under a Creative Commons Attribution 4.0 International License.