Duplicate cookies in Ruby on Rails

A cookie jar full of cookies.

Ruby on Rails has an easy-to-use cookie store for managing state between requests. It has affordances for storing clear-text values, tamper-proof signed values, and encrypted values. I previously showed how you can make good use of encrypted values for handling ActionCable authentication. However, there is a case where you might end up with duplicate cookies in Ruby on Rails applications.

This article discusses cookies, shared cookies, and how the use of the Ruby on Rails cookie store may lead to apparently duplicate cookies. From that, we’ll end with three rules of thumb for using cookies and avoiding the issue.

The Rails cookie store exposes a #cookies method that returns a Hash-like object on your controllers. For standard, visitor-editable cookies, the bracket-set operator is your friend:

cookies[:plain] = "value"

This results in a cookie that can be both read and edited on a client device:

A signed cookie works much the same from your application code. To create a signed cookie, send the #signed message with a bracket-set operator, like so:

cookies.signed[:signed] = "value"

This sets the value of the cookie to a signed blob of the form <payload>--<digest>. The payload is a base 64-encoded JSON document that has a base 64-encoded message containing the value set via your assignment. The digest is, as of Rails 6.1, an HMAC-SHA1 digest using your application’s secret_key_base as the key.

For the curious, the cookie value looks like this in the header:

eyJfcmFpbHMiOnsibWVzc2FnZSI6IkluWmhiSFZsSWc9PSIsImV4cCI6bnVsbCwicHVyIjoiY29va2llLnNpZ25lZCJ9fQ%3D%3D--20b6c117746e8b08ad4349ce4594e89bd47ef3c5

Lastly, an encrypted cookie follows the same pattern. It’s easy to access, only needing a change to #encrypted instead of #signed with the bracket-set operator:

cookies.signed[:encrypted] = "value"

Rails translates the cookie using a pattern similar to that of the signed cookie. It’s a blob of the form <payload>--<iv>--<auth_tag>. Breaking that down …

  1. The payload is a base 64-encoded, encrypted form of the JSON document from the signed implementation.
  2. The initialization vector (IV) the base 64-encoded value of the seed to the encryption algorithm, which we need to be able to later decrypt the value.
  3. The authentication tag is a base 64-encoded value that you can use to verify both the encrypted and plain text values of the payload. It, along with its delimiting dashes, is only present if you are using an “authenticated encryption with associated data” (AEAD) algorithm, which Rails 6.1 does by default.

Whew, that’s a lot to take in! For the (morbidly?) curious, here’s the cookie in header form:

x8s62U2CFfX0TgjuAcyX2eLi4PTLzSdWkjvHeYJ5zh8QO22fFj%2F%2BvM%2Fsk%2FV1u1lTKowWKrEe3YpQcYJ2yqRGUiiNMMWUqT9qcA%3D%3D--kwVMgQ4wOkYWXdQS--%2FcJ8VB6zVAsJ5aShfNGHaA%3D%3D

Now that we’ve seen the three different strategies, we can talk about trade-offs.

Trade-offs between methods

To talk about trade-offs, it will be handy to see each of the values side-by-side:

value
eyJfcmFpbHMiOnsibWVzc2FnZSI6IkluWmhiSFZsSWc9PSIsImV4cCI6bnVsbCwicHVyIjoiY29va2llLnNpZ25lZCJ9fQ%3D%3D--20b6c117746e8b08ad4349ce4594e89bd47ef3c5
x8s62U2CFfX0TgjuAcyX2eLi4PTLzSdWkjvHeYJ5zh8QO22fFj%2F%2BvM%2Fsk%2FV1u1lTKowWKrEe3YpQcYJ2yqRGUiiNMMWUqT9qcA%3D%3D--kwVMgQ4wOkYWXdQS--%2FcJ8VB6zVAsJ5aShfNGHaA%3D%3D

All three of these cookies contain the value "value". The plain cookie is of size 10, the signed cookie is size 148, and the encrypted one is size 1711. That shows that there is, at a minimum, a space trade-off when using each of these strategies. Since cookies cannot be larger than ~4,096 bytes2 , 3, the trade-off for signing and encrypting can add enough overhead to make problems for you.

However, you would not want to put your authentication cookie in plaintext for your visitors to monkey with! Imagine the trouble you could get in if they were able to masquerade as your account by changing a cookie value. Signed cookies prevent that from being an issue, but can still leak internal information.

For extremely important data, like said authentication cookies, use encrypted values and keep the value small.

Sharing them across multiple domains

Sometimes, you need to be able to access a cookie on more than one domain. Perhaps you want to be able to show your customer’s avatar on your marketing page, but it runs on a WordPress website instead of your Rails application. Or maybe you run a web application firewall in front of your application and it doesn’t support the WebSockets that you need for a feature (I’m looking at you Sucuri Firewall.)

Regardless of your use case, when you need to share a cookie between domains, you must change the syntax you use for setting cookies. It’s not a large change, but there is an extra level of memory needed for using this method.

First, a look at what the new form looks like:

# old
cookies[:key] = "value"

# new
cookies[:key] = { value: "value", domain: :all }

That :all is a wildcard specifier meaning *.<my_domain>, or any subdomain of the primary top-level domain. So if you’re running an app at app.cool.io and your marketing site runs on www.cool.io, the :all option will work for you.

Otherwise, you can specify the domain(s) to which the cookie will apply.

Duplicate cookies?

Consider the following scenario. In one place of your application, you set a shared cookie as follows:

cookies[:key] = { value: "value", domain: :all }

In another spot, you nil out the value to remove it from the cookie jar, but you do it like so:

cookies[:key] = nil

When you make several subsequent requests to the application, calling these lines in any number of orders and counts, what would you expect the behavior to be?

In some cases4, you might end up with a cookie set for both mysite.com and .mysite.com! That can lead to some unexpected behavior if you’re not aware it’s a possibility because the cookie that “wins” the fight is the last one to appear in your request headers.

When this cookie relates to authentication, like handling ActionCable authentication, it can lead to exasperating issues where every other request might come through as unauthenticated.

Rules of thumb

With all of these learnings, we can describe three rules of thumb for using cookies in your Ruby on Rails application:

  1. Use the level of cookie that makes sense for the domain. When you handle authentication and authorization, prefer at a minimum, signed cookies. When it doesn’t truly matter if someone changes the value, prefer plain cookies.
  2. Store smaller, ideally primitive, values in a cookie to keep the size down. If you need a rich structure, consider storing an identifier to a record in your database.
  3. When you need to share a cookie between domains, ensure that you always use the same domain: parameter when setting the cookie. In fact, there’s a line in the ActionDispatch::Cookies documentation about that, but it’s easy to miss.

Applying the third rule can take many forms, but once you do, you will see the problem of duplicate cookies disappear.

Conclusion

The cookie store in Ruby on Rails is very easy to work with but has a few sharp edges. Because it’s easy to use, it’s easy to make mistakes. Knowing when and how to use the different types of cookies will help you avoid one class of error.

But even knowing those basics still leaves a spot for error. When you need to share cookies between domains, there’s a possibility that you end up with duplicate cookies in your app.

Apply your discipline to the use of cookies and you’ll see your errors vanish before you.

Have you ever experienced a duplicate cookie in your Ruby on Rails application? How about those tricky authentication cookies - have you ever made a mistake there or discovered one in another app?


  1. It’s slightly important to note here that the values for the signed and encrypted cookies are inflated by 1 and 4, respectively. Cookie sizes are the size of the cookie name plus the size of the value. Since I used the cookie names “plain,” “signed,” and “encrypted,” the names are of varying size. Keep that in mind when you’re picking names for your cookies! ↩︎

  2. Interestingly, the spec, also known as RFC6265, does not specify a maximum size. It’s entirely up to browser vendors to implement, though they all agree that 4KiB is the maximum length they’ll support. Some browsers have overhead though, so it’s wise not to approach this value. ↩︎

  3. Please, consider the effect of large cookies. For every request and every response, the browser transmits the cookie. As such, if you have a 4,000-byte cookie you’re tacking on an extra 8KiB of data per page for someone using a mobile device. ↩︎

  4. I haven’t been able to put my finger on when this happens, but it feels like either a race condition or an order-dependent condition. From anecdotal reloading of a page that non-deterministically sets the value of a cookie to the current domain or to :all, I end up seeing both cookies set after a few reloads. If I track down the exact behavior, I’ll update this post. Do you know the cause? If so, please let me know! ↩︎