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!
Get Test Driving Rails while it's in prerelease.