Sessions Are Not Server Memory
In a default Rails app, the session is not a server-side hash. It is serialized, encrypted, and carried by the browser as a cookie.
It usually starts with a good instinct: saving a database query.
You have a heavy controller action or a complex multi-step wizard. You need the Account object across multiple requests. Re-querying it on every request feels wasteful, so someone reaches for the closest thing that looks like request-to-request storage:
class AccountsController < ApplicationController
def switch
@account = current_user.accounts.find(params[:id])
# Memoize this so we do not have to query it on the next request, right?
session[:current_account] = @account
redirect_to dashboard_path
end
endAt the line where it is written, this looks harmless. session behaves like a hash. The assignment succeeds. The action redirects. Nothing about the controller tells you that you just asked Rails to serialize an Active Record object into an HTTP cookie.
In development, if the @account record is simple, it might even appear to work.
In production, as the application matures and that record accumulates loaded associations, it eventually blows up. The confusing part is that it doesn’t fail in your controller. It fails deep in the framework, long after your action has finished executing:
F, [2026-06-05T14:18:42.221934 #18412] FATAL -- :
[7f4f7d18-7c91-47c9-9cb9-01ec8d2b77c2]
ActionDispatch::Cookies::CookieOverflow (_billing_session cookie overflowed with size 6128 bytes):
[7f4f7d18-7c91-47c9-9cb9-01ec8d2b77c2]
actionpack (8.1.1) lib/action_dispatch/middleware/cookies.rb:616:in `check_for_overflow!'
actionpack (8.1.1) lib/action_dispatch/middleware/cookies.rb:698:in `commit'
actionpack (8.1.1) lib/action_dispatch/middleware/session/cookie_store.rb:117:in `set_cookie'
actionpack (8.1.1) lib/action_dispatch/middleware/session/abstract_store.rb:72:in `commit_session'The stack trace points at commit_session, not AccountsController#switch.
That is the first important clue. The controller action has already finished. Rails is back in the middleware stack, trying to turn the response into bytes the browser can understand. Only then does the oversized session become a concrete failure.
This exposes a common broken assumption about state in Rails: the session is not a Ruby hash sitting in server memory. In a default Rails application, the session is backed by ActionDispatch::Session::CookieStore. When you write to it, you are not putting data into a process-local cache. You are asking Rails to serialize that data, encrypt it, authenticate it, and send it back to the browser as a cookie.
You did not save the object on the server. You packed it into a cookie and asked the browser to bring it back on the next request.
Where the Session Goes
By the time your controller action runs, Rails has already made the session available through the request context. That part feels ordinary:
session[:user_id] = user.idAfter the action returns, the response travels back up the Rack middleware stack. ActionDispatch::Session::CookieStore gets a chance to persist the session.
CookieStore does not save the session to a database table or to Redis. It writes the session data into the cookie jar, using the configured session cookie key:
Rails.application.config.session_store :cookie_store, key: "_billing_session"From there, the encrypted cookie jar takes over. For a modern Rails app using encrypted cookies, the flow looks like this:
session hash
->
serialized payload
->
encrypted and authenticated message
->
cookie value
->
Set-Cookie response headerRails checks the final cookie name plus the encrypted value. If the result exceeds the cookie size limit, the response cannot be committed.
The controller line looked like a hash assignment. The system behavior was an HTTP write.
The Serialization Pipeline
Let’s trace how a Ruby hash becomes an HTTP header.
The CookieStore handoff is small, but important:
# action_dispatch/middleware/session/cookie_store.rb
def set_cookie(request, session_id, cookie)
cookie_jar(request)[@key] = cookie
end
def cookie_jar(request)
request.cookie_jar.signed_or_encrypted
endThat method writes the session value into the signed or encrypted cookie jar.
From there, the encrypted cookie jar does three things:
serialize the session
encrypt and authenticate the payload
check whether the final cookie is too largeThe Rails source compresses that final step into a few lines:
# action_dispatch/middleware/cookies.rb
def commit(name, options)
super
options[:value] = @encryptor.encrypt_and_sign(
options[:value],
**cookie_metadata(name, options)
)
check_for_overflow!(name, options)
endThe important detail is not the exact encryption setup. It is the direction of travel:
session hash
->
serialized payload
->
encrypted cookie value
->
Set-Cookie headerNew Rails applications use JSON serialization for cookies by default, though upgraded applications may still carry older serializer settings such as Marshall or hybrid modes. After serialization, Rails uses ActiveSupport::MessageEncryptor with keys derived from secret_key_base to seal the payload.
In a modern AES-GCM encrypted-cookie setup, the browser stores a value with this general shape:
sJqiplFlaBri/tctIKNc...--+TZCVdk0e/RzsuUf--W3J114mcEMz00DoeuAUNSw==The pieces are the encrypted payload, the initialization vector, and the authentication tag.
If any part of that message is changed, Rails cannot decrypt and verify it. The session data is not accepted.
The exact cookie format can vary across Rails versions, cipher settings, metadata settings, and rotations. But the architecture is stable: CookieStore writes through the cookie jar, MessageEncryptor seals the payload, and the browser carries the sealed result back.
This pipeline also explains why the CookieOverflow appears after the controller action has finished. The oversized value is discovered when Rails tries to commit the encrypted cookie.
The session is encrypted and tamper-resistant, but the physical limits of HTTP headers still bind it.
The Four Kilobyte Ceiling
CookieStore is fast because it avoids server-side lookup of the session blob. It is also brutally small.
Browsers generally enforce a limit of around 4 KB (4096 bytes) per cookie. Rails checks the size of the cookie name plus the encrypted value before writing it. Encryption and metadata add overhead, so the useful payload is smaller than the raw browser limit.
That is why this mistake is so easy to misread:
session[:current_user] = @accountThe same mistake often arrives through the flash:
redirect_to root_path, flash: { error: @account }That looks like a temporary redirect message, but the flash is carried through the session. If the session is CookieStore, a large flash payload is still a large cookie payload.
You are not only storing the account’s ID. Depending on the serializer and object shape, you may be asking Rails to encode attributes, timestamps, loaded association data, dirty tracking state, or some other object representation that was never meant to cross the request boundary.
The failure mode is not always a clean overflow.
In a JSON-serialized app, a complex object often comes back as plain serialized data, not a live model:
session[:current_account] = @account
# Next request
session[:current_account].class
# => HashNow the bug depends on how the rest of the application uses that value. Code that only reads [“id”] might limp along. Code that expects an Account instance fails. Code that reads stale attributes may quietly make decisions from yesterday’s state.
In older Marshall or hybrid-serializer applications, the failure can be worse in another direction: the object-shaped value may round-trip more successfully, making it easier to believe that the session is storing application state safely. But that object is detached from the database. Its associations do not become fresh when a new request starts. Authorization, billing state, feature flags, account memberships, and plan limits can all change while the cookie still carries an outdated snapshot.
That is the trap.
The session can carry identity across requests. It should not carry the state that identity points to.
The Distributed Reality
Once you see the 4 KB limit, CookieStore can look oddly constrained. Why would Rails make this the default?
The answer lies in how a mature Rails application actually runs in production.
When you boot a Rails app locally with bin/rails server, you are often looking at a single process on a single machine. In that isolated environment, a global in-memory hash can appear to work. Production environments do not remain in that state for long.
A standard production config/puma.rb looks something like this:
# config/puma.rb
workers ENV.fetch("WEB_CONCURRENCY") { 4 }
threads_count = ENV.fetch("RAILS_MAX_THREADS") { 5 }
threads threads_count, threads_count
preload_app!When this boots, Puma forks into four distinct worker processes. These are isolated operating system processes. Each process has its own Ruby heap. Threads inside a worker share memory, but workers do not.
Imagine you decided to build your own session store using a global Ruby hash. A user logs in, the load balancer routes their request to Worker 1, and you save GLOBAL_SESSIONS[session_id] = { user_id: user.id }.
When the next request lands, the load balancer might route it to Worker 3. That worker looks at its own isolated GLOBAL_SESSIONS hash, finds nothing, and boots the user back to the login screen.
From the user’s perspective, the application randomly forgot who they were.
You can try to patch this with sticky sessions at the load balancer, forcing a user to keep hitting the same backend. That only hides the coupling. It still gets awkward across deploys, worker restarts, autoscaling events, and multi-host routing.
Teams that need server-side revocation, larger session payloads, or compliance controls sometimes move session storage into Redis or a database-backed store. That changes the trade-offs: network calls, eviction behavior, cleanup, persistence, and capacity planning all become part of the session story. But it does not change the core lesson. Session should carry identity, not application state.
CookieStore chooses a different trade-off.
The browser carries the sealed session payload. Every Rails process that shares the same cookie configuration and secret_key_base can read it. Worker 1 does not need to remember what Worker 3 wrote.
The framework trades payload size for stateless web workers.
The Invalidation Problem
Once you accept that the session lives on the client, you run into the hardest problem of stateless architecture: invalidation.
Suppose a user’s account is compromised, or an administrator removes their access. You need to log them out immediately. Because the state is stored in a cookie, you cannot reach into every browser and delete it.
The naive approach is to add a database flag and check it on every request:
# The naive approach: Check this on every single request
class ApplicationController < ActionController::Base
before_action :check_revoked_status
def check_revoked_status
if current_user.access_revoked?
reset_session
redirect_to login_path
end
end
endThis can be the right product behavior. It blocks the user as soon as the application sees the revoked state.
But it does not invalidate the cookie. The encrypted session still decrypts, and the browser still sends it. If someone copied that cookie earlier, they can replay the same encrypted string until the server rejects the state inside it. That is the replay attack shape this gate is defending against. The application had added a server-side check in front of the session, but it did not remotely destroy every copy of the cookie.
For global invalidation, the hard level is the secret material.
Encrypted session cookies are protected by keys derived from session_key_base. If you change that secret and do not accept the old one through cookie rotations, existing session cookies become unreadable across the fleet. That is effective during a serious security event, but it logs out everyone.
Cookie ratation needs precise language. Rails rotations are usually for migration: accept messages written with old keys while writing new ones with the new configuration. If the old key remains accepted, old sessions are not invalidated.
For a targeted invalidation, you need some server-side fact that can change.
The simple version is a user-level session token. Instead of treating user_id alone as enough, you store a random token in the session and compare it against the current token on the user:
class User < ApplicationRecord
# Assume a `session_token` string column exists
end
# 1. During sign-in:
session[:user_token] = user.session_token
# 2. In ApplicationController:
def current_user
@current_user ||= User.find_by(session_token: session[:user_token])
endNow, when you need to log a user out of all devices forcibly, you rotate the token in the database:
def force_logout!(user)
user.update!(session_token: SecureRandom.urlsafe_base64)
endThe next time that user makes a request, their browser sends the old token. User.find_by returns nil. They are logged out without changing anyone else’s session.
That model invalidates all devices for one user. If you need to revoke one device at a time, use a dedicated UserSession record and store a per-session token in the cookie. That rule is the same either way: CookieStore can carry the token, but the server-side record decides whether the token still means anything.
Store Identity, Not State
Rails goes out of its way to make the web feel stateful. session[:key] = value is one of the most elegant APIs in the framework, but its simplicity hides a distributed systems reality.
When you are deciding what to put in the session, use this rule of thumb: the session is a specialized transport mechanism, not a storage engine. Store identity, not state. Store IDs, not objects. The moment you try to use the client’s browser as a caching layer for your database, you are fighting the architecture of the framework itself.
Once the session contains only a user identifier, another question appears: where does current_user come from on the next request?

