Rotate cookies when upgrading to Rails 7
Rails 7 brought a number of new features as well as breaking changes, which made the upgrade from 6.1 more troublesome than the previous one. Rails modified the default cookie serilaizer and the default encryption method, causing incompatibility between the old and new cookies, so the upgrade needs to be done in steps to ensure the best user experience.
What is a cookie
How cookies work in Rails
There are several ways to manipulate cookies in Rails. A normal cookie can be set in the controller with something like
cookies[:user_id] = 1, while for signed or encrypted cookies the following is used:
# Signed cookies cookies.signed[:user_id] = 1 # Encrypted cookies cookies.encrypted[:user_id] = 1
The signed cookie uses
secrets.secret_key_base to sign the cookie, which guarantees the integrity of the cookie content, but not the confidentiality of the cookie. An encrypted cookie encrypts the content and guarantees confidentiality.
If you set an encrypted cookie in the controller, you can only see the encrypted cookie in the browser, but not the exact contents of the cookie, while using
cookies.encrypted[:user_id] in the controller, you can get the decrypted contents.
Changes in Rails 7 involving cookies
After upgrading to Rails 7, the cookie serializer switched from Marshal to JSON. Also, digest class switched from SHA1 to SHA256, which means that any information previously encrypted by Rails will be invalidated. This may invalidate the user's login status or other information, so we need some strategy to make this switch indifferent to the user.
Migrate cookie serializer from Marshal to JSON
Marshal and JSON are the two serialization methods in Ruby. Marshal is the serialization method natively supported by Ruby, but it has the following drawbacks:
- It is not secure enough and can lead to remote code execution by constructing malicious objects;
- It is not flexible enough, and can only be deserialized in a Ruby environment.
Therefore, Rails 7 has been upgraded to JSON serialization approach to improving the security and cross-language compatibility of the application.
To address this, we can follow these steps to make the switch:
Complete the upgrade to Rails 7 and deploy it so that the other changes can take effect in the production environment, but configure the serializer to still use Marshal serialization:
Rails.application.config.action_dispatch.cookies_serializer = :marshal
Using a serializer in Hybrid mode, Rails 7 provides a serializer configuration item called
:hybridthat automatically serializes past cookies to JSON on the next user visit:
Rails.application.config.action_dispatch.cookies_serializer = :hybrid
After a certain amount of time, switch to JSON serialization completely, but you can also use
:hybridas the final configuration. If you intend to use JSON serialization completely, then you can remove
Rails.application.config.action_dispatch.cookies_serializerto use the default 7.0 default configuration.
Note that since cookies are no longer serialized by Marshal, this means that you can no longer put some complex Ruby objects into cookies. In general, using Hash, String and Numeric would be safer options.
Solution to Digest Class changes
Due to security concerns, Rails 7 switched from SHA1 to SHA256 for the default digest class.
# Change the digest class for the key generators to `OpenSSL::Digest::SHA256`. # Changing this default means invalidate all encrypted messages generated by # your application and, all the encrypted cookies. Only change this after you # rotated all the messages using the key rotator. # # See upgrading guide for more information on how to build a rotator. # https://guides.rubyonrails.org/v7.0/upgrading_ruby_on_rails.html Rails.application.config.active_support.key_generator_hash_digest_class = OpenSSL::Digest::SHA256
This means that any information previously encrypted by Rails will be invalidated. Therefore, a cookie rotator needs to be written to solve this problem. Here is the official sample code given below:
# config/initializers/cookie_rotator.rb Rails.application.config.after_initialize do Rails.application.config.action_dispatch.cookies_rotations.tap do |cookies| authenticated_encrypted_cookie_salt = Rails.application.config.action_dispatch.authenticated_encrypted_cookie_salt signed_cookie_salt = Rails.application.config.action_dispatch.signed_cookie_salt secret_key_base = Rails.application.secret_key_base key_generator = ActiveSupport::KeyGenerator.new( secret_key_base, iterations: 1000, hash_digest_class: OpenSSL::Digest::SHA1 ) key_len = ActiveSupport::MessageEncryptor.key_len old_encrypted_secret = key_generator.generate_key(authenticated_encrypted_cookie_salt, key_len) old_signed_secret = key_generator.generate_key(signed_cookie_salt) cookies.rotate :encrypted, old_encrypted_secret cookies.rotate :signed, old_signed_secret end end
The above code switches the encryption method from SHA1 to SHA256 and implements rotation of the old cookie.
We rarely do the cookie encryption manually ourselves in app development, more commonly we use the gem devise and devise will use Rails.application.secret_ key_base for user login-related cookie encryption, and the above code can be placed in the initializer to rotate the old cookie and avoid the need to log in again. However, you also need to pay attention to whether other gem use
ActiveSupport::KeyGenerator related code, if so, you also need to perform similar rotation.