Notes to self

Migrating Rails cookies to the new JSON serializer

How to move from Marshal to the new Rails 7 default JSON serializer.

I was recently upgrading Phrase to Rails 7. Big upgrades like that are usually being done with the most minimal changes, and this one wasn’t an exception. However, every major and minor version of Rails brings some new defaults that can accumulate over time, leaving you with some debt to pay.

Today I want to talk about the JSON serializer for cookies that became the default starting with Rails 7.0.

Marshal vs JSON

So what’s up with the new JSON serializer? The previous cookie serializer conveniently used Marshal to convert any Ruby object into a byte stream and allowed us to store pretty much anything as long as it fit 4096 bytes (browser’s limit). This same flexibility is also why marshaling is not considered secured and the reason behind the Rails 7 change to save cookies as JSON.

The main problem of this change is that your current users have a bunch of cookies already saved in their browsers, so if you changed the serializer without changing your cookies, you could find yourself with a broken application.

The plan

The plan to change the cookies to JSON consists of:

  • Upgrading to Rails 7 while keeping the old Marshal serializer
  • Making a list of cookies and their data types
  • Converting data types when reading the cookies
  • Moving to the :hybrid serializer
  • Changing the way we save cookies
  • Moving from :hybrid to :json

Upgrading to Rails 7

Rails has us covered when it comes to upgrading to version 7. After running rails app:update we’ll get a new initializer with all new Rails 7 defaults in one place. We are interested in the following part:

# config/initializers/new_framework_defaults_7_0.rb
...
# If you're upgrading and haven't set `cookies_serializer` previously, your cookie serializer
# was `:marshal`. Convert all cookies to JSON, using the `:hybrid` formatter.
#
#
# If you're confident all your cookies are JSON formatted, you can switch to the `:json` formatter.
#
#
# Continue to use `:marshal` for backward compatibility with old cookies.
#
#
# If you have configured the serializer elsewhere, you can remove this.
#
#
# See https://guides.rubyonrails.org/action_controller_overview.html#cookies for more information.
# Rails.application.config.action_dispatch.cookies_serializer = :hybrid

Because we don’t know what would happen to our code getting JSON cookies, we need to start with the old default. We set your serializer to :marshal and continue with the upgrade:

Rails.application.config.action_dispatch.cookies_serializer = :marshal

This allows us to upgrade Rails and safely revert the upgrade if needed. Once on Rails 7, we can attempt to fix our cookies.

Cookies list

Since every cookie can be saved and read differently, we need to identify all cookies (including signed and session cookies) in the application. This can start with a simple search for cookies and session, but let’s not forget that our dependencies might use cookies too. You should have a look for and check cookie storage in your browser’s dev tools. Make a list and group cookies by data type you are saving. Your marketing and legal department might also be interested in this.

The list can look like the following:


# Cookies

## Strings

- cookies[:language]

## Integers

- session[:oauth_access_token_id]

Converting data types

The main problem when upgrading to JSON is that cookies will end up with different data types. So we should prepare a list of the data types we found and ensure we understand how the value will change.

If you are unsure what will happen, set a cookie in your controller and read it back using the new default:

# in config
Rails.application.config.action_dispatch.cookies_serializer = :json

# in a controller
cookies[:test] = { key: "value" }
puts cookies[:test].inspect

I’ll note some main data types and how we have to change the code reading the values from these cookies.

  • Strings

    Strings in Marshal will be strings in JSON. Change is not required.

  • Atoms

    Atoms won’t stay atoms but would be converted to strings. The fix is to call to_sym on the value from the cookie.

  • Booleans

    Booleans won’t stay booleans but will be returned as strings. The fix is to cast the returned values using ActiveModel::Type::Boolean.new.cast.

  • Integers

    Integers won’t stay integers but will be returned as strings. The fix is to call .to_i on returned values.

  • Hashes

    Hashes will stay hashes but atom keys will end up being strings. The fix is to convert such a cookie to HashWithIndifferentAccess. Note that hashes can contain values that might need change as well.

  • Ruby objects

    The last category is all the other Ruby objects and won’t stay the same. For example, ActiveSupport::Duration would be returned as a string in seconds.

In the end, things will be JSON, so if you need a more complicated structure, convert it to JSON, and then you can pass it to the cookie.

Moving forward

Once we update how we read our cookies, we can switch to the :hybrid mode and deploy our change.

Rails.application.config.action_dispatch.cookies_serializer = :hybrid

The main work is done. Give yourself a break!

Saving cookies

To properly finish up, we should go back to every cookie and make sure we are already saving it with the expected data type. This will improve clarity. So instead of keeping an integer, we can call .to_s and pass a string explicitly.

Leaving hybrid mode

After a reasonable time in production, we can now remove the :hybrid option from the configuration (you can delete the whole setting). You have made it!

Check out my book
Interested in Ruby on Rails default testing stack? Take Minitest and fixtures for a spin with my latest book.

Get Test Driving Rails while it's in prerelease.

by Josef Strzibny
RSS