When Logging Out Doesn't Log You Out: Moving Rails Sessions to the Database
Rails’ default cookie store keeps no record of the sessions it issues. Logout clears a cookie and nothing more. How we moved sessions into the database.
Jędrzej Graczykowski
Ruby Developer
A security audit of our client's application came back with a finding we could reproduce in a single command.
The application issued a session cookie on login. It then issued a new one on every subsequent request. None of the old cookies were ever invalidated. Clicking logout did not invalidate anything either. Cookies handed out days earlier still worked.
Here is the proof of concept. Grab a session cookie, wait three days, click logout in the browser, then replay the request:
curl -i https://app.example.com/dashboard \
-H "Cookie: _myapp_session=SFMyNTY...truncated"
The expected response is a redirect to the login page. We got 200 OK and the full page.
The auditor recommended keeping one cookie instead of minting a new one on every request, extending it rather than replacing it, invalidating it on logout, and shortening the session lifetime. The diagnosis was correct. The recommended fix, as we found out, could not deliver what the report was asking for.
Why the cookie store cannot log anyone out
The application used the Rails default, ActionDispatch::Session::CookieStore, with Devise and Warden on top. Session data is serialized, encrypted and signed with secret_key_base. All of it lives in a cookie on the client. Authentication comes down to a user id inside that payload.
The server keeps no record of the sessions it issues. On every request Rails verifies the signature and decrypts the payload. If that succeeds, the session counts as valid. There is no session lookup, because there is nothing to look up.
Expiry works differently than you might expect. Session timeout is handled by Devise's timeoutable module, which writes a last-request timestamp into the session. That means into the cookie. The idle window itself is application logic. The app is multi-tenant and every account configures its own timeout policy. On each request Devise compares the timestamp the client just presented against the window it reads at that moment.
There is a comparison, then, but not against any record of the session. The clock travels inside the cookie. The limit it gets measured against is looked up fresh every time. This is what makes the finding structural rather than a matter of tuning: widening an account's timeout window brings expired session cookies back to life. We confirmed it by narrowing and widening the timeout repeatedly. Access came back every time it widened. Nothing about the cookie had changed when it stopped being accepted. Nothing had to change for it to be accepted again.
Logout has the same shape. Devise clears the cookie in the browser. That is all it can do, because there is no record to mark as ended. Any copy of that cookie that exists elsewhere keeps working: pulled from a proxy or application log, captured in transit, left behind in a shared browser profile, or simply kept by whoever obtained it. The user pressed the button. The button did what it could. The session stayed alive.
Some invalidation is possible. Devise derives part of the session key from the user's encrypted password, through authenticatable_salt. The salt travels in the session and gets compared on every request. Change the password and every session belonging to that user stops validating at once. Rotate secret_key_base and every session in the application does.
Both work. Neither is session management. You can end all of a user's sessions, never one of them, and never without changing a credential. Nobody can look at the system and say which sessions are outstanding, because that information does not exist anywhere.
A short enough window makes this easier to live with. It still leaves no way for a user to be sure a particular session is over, and no way for an administrator to end one on demand. You cannot revoke what you do not track.
The cookie had another problem
The audit was not the only thing pointing away from the cookie store. Some time earlier the application had started returning 502s, seemingly at random, to a small number of users.
The encrypted session cookie had grown past 4 KB. Rails raises CookieOverflow at that size. Our users hit a different wall first: nginx returned 502 once the Set-Cookie header outgrew proxy_buffer_size. The error arrives from the proxy rather than the application. Nothing in the Rails logs says "session". It took a while to connect a gateway error to a session payload.
The cookie store asks something else of you besides giving up revocation. Everything the session holds travels on every request and every response. It has to fit inside limits set in two different places. Moving the data server-side leaves a session id in the cookie. The size question disappears with it.
Where Rails can keep sessions
Rails supports several session stores. We compared them on three questions: can a session be invalidated server-side, does it survive an application restart, and what does it depend on to run?
| Store | Server-side invalidation | Survives restart | Depends on |
|---|---|---|---|
cookie_store |
No | Yes (data lives on the client) | Nothing |
activerecord-session_store |
Yes | Yes | Your database |
cache_store |
Yes | Depends on backend | Whatever Rails.cache uses |
mem_cache_store |
Yes | No | Memcached |
redis-actionpack / redis-session-store |
Yes | Yes (with persistence) | Redis |
aws-sessionstore-dynamodb |
Yes | Yes | DynamoDB |
memory_store |
Yes | No | Nothing |
:disabled |
N/A | N/A | Nothing |
The cookie store is the Rails default and the fastest of the group, because there is no storage to consult. It is also capped at 4096 bytes. A session usually holds a user id, a CSRF token and a flash message. That is why teams rarely think about the limit. Ours found it anyway. Whatever else ends up in the session has to fit inside the cookie. It also has to fit inside whatever sits in front of the application.
activerecord-session_store keeps sessions in a regular database table. It was part of Rails until version 4, when it got extracted into its own gem. The cookie then carries nothing but a session id.
Then there are the key-value stores. cache_store puts sessions wherever Rails.cache points. mem_cache_store targets Memcached. redis-actionpack and redis-session-store talk to Redis directly. All of them are fast. Redis also brings native TTL.
The trouble starts when memory runs out. A cache is normally configured to cope by throwing away whatever has not been used for a while. That is correct for cached data and wrong for sessions, where the thing thrown away is someone's login. Configured the other way, the store stops accepting writes instead. That is its own kind of outage. Sharing one instance between cache and sessions means one setting has to serve two workloads that want opposite things.
In Rails 8, where Solid Cache is the default cache backend, cache_store sessions end up in the database anyway. The line between this group and the previous one is thinner than it looks.
aws-sessionstore-dynamodb fits serverless or multi-region setups. memory_store keeps sessions in the process. That works in development and breaks behind more than one worker.
:disabled means no sessions at all. Rails skips the session middleware and session becomes unavailable. Everything built on it goes too, including the flash and the standard CSRF token. API-only applications choose this deliberately. They authenticate every request from a token or header and have no server-side conversation to remember.
If none of these fit, the interface is small enough to implement yourself. Subclass ActionDispatch::Session::AbstractStore and provide find_session, write_session and delete_session.
What we rejected
Versioning cookie sessions
The smallest change we considered looked like this. Add a session_version attribute to the user, bump it on every login, clear it on logout, and write its value into the session. A cookie carrying a stale version stops being accepted. Only cookies issued during the current login-to-logout window keep working. No new table, no new gem, no change of store.
It solves half of what the audit reported. Logout starts invalidating sessions. The cookie of a user who never logs out still lives for the whole account timeout window. It also stays revivable by widening that window, exactly as before. The report flagged both symptoms and we wanted both closed.
The deciding argument was a different one. Versioning modifies the authentication mechanism that currently works instead of putting an alternative next to it. Any change to session management has two unpleasant failure modes: it logs everybody out, or, much worse, it quietly does nothing and leaves open the hole the audit just reported. When the change is woven into the existing path, backing out means another change to that same path. When it sits behind a flag next to the existing store, backing out means running the code that has been working all along.
There is also nothing in it beyond the fix. Sessions stay invisible to the server. We still could not answer who is logged in right now, or which IP used a given session.
Redis or PostgreSQL
Once you decide to keep sessions server-side, Redis is the reflexive first choice. It has one concrete argument going for it: native TTL. Set an expiry when you write the session and the store enforces it. There is no cleanup code to maintain.
That argument does not hold in this application. Expiry here is not a value known at write time. It is a policy read at request time, the same property that let a widened window revive dead cookies. A TTL stamped on the record at creation would be wrong the moment an account changed its setting. We would have to compute expiry in the application layer anyway. The cleanup we were hoping to avoid would have to be written and scheduled regardless.
What remained was speed, and speed was not the constraint here. A request that writes the session costs an indexed lookup and a write, plus a second write of our own for the metadata. That is more than a Redis round trip, and it still does not register at this application's traffic level. PostgreSQL was already running, already backed up and already familiar to the team.
There is also a difference in what each choice commits you to. Redis is in this application for background jobs and caching. Without it the application degrades rather than stops. Sessions are not like that. Put them in Redis and a Redis outage becomes an outage of logging in. That turns an optional component into a hard dependency. PostgreSQL is a hard dependency already. Sessions add nothing to the list of things that must be up.
What we get back is that sessions become ordinary rows. They are backed up with everything else. They join against users and accounts. Anyone debugging a support ticket can look at them with the tools they already use.
The implementation
We went with activerecord-session_store, behind an environment variable.
# config/application.rb
config.use_database_session_store = ENV['USE_DATABASE_SESSION_STORE'] == '1'
# config/initializers/session_store.rb
if Rails.configuration.use_database_session_store
Rails.application.config.session_store :database_session_store,
key: '_myapp_session'
else
Rails.application.config.session_store :cookie_store,
key: '_myapp_session'
end
The Session model provides the interface the gem expects, plus the operations we needed:
class Session < ApplicationRecord
belongs_to :account, optional: true
belongs_to :user, optional: true
def self.find_by_session_id(session_id)
find_by(session_id: session_id)
end
end
The store subclass points the gem at that model and fills in what the gem does not track:
require 'action_dispatch/session/active_record_store'
class DatabaseSessionStore < ActionDispatch::Session::ActiveRecordStore
def self.session_class
::Session
end
def session_class
::Session
end
private
def write_session(request, sid, session_data, options)
logger.silence do
record, sid = loaded_session_model(request, sid)
record.data = session_data
assign_session_metadata(record, request, session_data)
record.save ? sid : false
end
end
def loaded_session_model(request, sid)
record = request.env[SESSION_RECORD_KEY]
return [record, sid] if record && sid && record.session_id == sid.private_id
get_session_model(request, sid)
end
def assign_session_metadata(record, request, session_data)
record.assign_attributes(
user_id: extract_user_id(session_data)
# plus whatever else is worth keeping per session
)
rescue => e
Rails.logger.warn("Failed to update session metadata: #{e.message}")
end
# ...
end
The gem stores the session id and the serialized data. It does not know who the session belongs to, because Rails sessions are not an authentication concept. Warden writes the user key into the session like any other value. extract_user_id reaches into the session data and pulls it back out. A session row then knows its user, and "who is logged in right now" becomes a query instead of a guess.
The obvious way to add that is to call super and update the row afterwards. It costs three statements per write where one would do: the gem's lookup and save, then a lookup and update of our own. Replacing write_session instead of wrapping it avoids both. The record the middleware already holds in request.env[SESSION_RECORD_KEY] is reused when it matches the incoming id, falling back to the gem's own lookup when it does not. The metadata is assigned to that record before it is saved, so it reaches the database in the same write as the session data.
One thing to watch is what you put in there. Anything already inside the session data is free. Anything that has to be resolved, such as which account a user is currently working in, costs its own query on every write. The rescue matters for the same reason: tracking is useful, signing in is essential, and a failure to record metadata should not take a session down with it.
JSON instead of Marshal
The gem serializes session data with Marshal and encodes the result in Base64.
Marshal means that anything able to write to the sessions table can make the application deserialize arbitrary Ruby objects. It also couples stored sessions to class names. Rename a class between deploys and the sessions written before that deploy stop loading. JSON avoids both problems.
If you use the gem's model, switching to JSON is one line:
ActiveRecord::SessionStore::Session.serializer = :json
We do not use its model, so that line would have set a value on a class we never touch. Serialization happens in the data and data= accessors of whatever class actually stores the row. Ours defines its own, so that is where the format is decided:
def data
return {} if self[:data].blank?
JSON.parse(self[:data])
rescue JSON::ParserError => e
Rails.logger.error("Failed to decode session data for session_id=#{session_id}: #{e.message}")
{}
end
def data=(value)
self[:data] = JSON.generate(value)
end
It is an easy thing to get wrong. Set the serializer on the gem's class, keep your own accessors, and nothing fails: sessions keep working and keep being written in the old format. The setting is read by code that is no longer in the path.
JSON has a cost of its own. It turns symbols into strings, and Time values into strings. Any code that writes one of those into the session and reads it back gets a different type than it put in. Check what your application actually stores before switching. The change is also not backwards compatible. It has to happen while there are no rows to read, or behind the same one-time logout as everything else.
Whichever serializer you pick, the table holds session contents in a form that anything with read access can decode. Neither Base64 nor JSON is a security measure. Read access now includes replicas, backups and dumps. The same goes for anything you add to the table for auditing. An IP address or a user agent stored per session is personal data that did not exist anywhere in the system before. It needs a retention answer rather than an indefinite lifetime.
Rejecting cookies from the old store
Switching stores does not empty anyone's browser. For days afterwards, requests arrive carrying a cookie written by the cookie store: a long encrypted payload under the same cookie name the new store now reads.
Without a check, that payload gets treated as a session id and looked up. It will not match anything, so nothing dangerous happens. The application still spends a query on every one of those requests. Invalid UTF-8 in a cookie is worse: matching a regular expression against it raises instead of returning false. So the store validates the id before using it:
SESSION_ID_FORMAT = /\A\h{32}\z/
def extract_session_id(request)
sid = super
sid if sid && generated_id?(sid.public_id)
end
def generated_id?(value)
value.valid_encoding? && SESSION_ID_FORMAT.match?(value)
end
Only a 32-character hex public id gets through. The private id does not. That deserves a spec of its own: a cookie carrying the private id must not resume a session.
Signing out
Signing out needs no code of its own. Devise calls reset_session and the store deletes the row. The next request arrives with a session id that matches nothing.
That is the difference the audit was asking for. The cookie in the browser is no longer the thing that decides whether a session is alive. It is a pointer. The row it points at is ours. Deleting that row ends the session for every copy of the cookie at once, wherever those copies ended up. The same mechanism works outside a logout request, because deleting the row is all it takes. Ending somebody else's session, or every session belonging to one user, is a query away.
The guarantee lives in code we do not own. Nothing in our own code would look wrong if a gem upgrade changed it. A request spec is what notices: sign in, confirm the row exists, sign out, confirm it is gone.
Cleanup
Rows accumulate. With a cookie store an anonymous visitor costs nothing on the server. With a database store every visitor gets a session record that is updated on each request. The table fills up with rows that were never associated with a user.
The rule works on idle time. Sessions with no user are kept for a day before being removed. That is long enough that nobody loses a login attempt to the cleanup job, which matters more than it sounds: a visitor part-way through signing in has a row with no user on it. Sessions that belong to a user are kept far longer: thirty days, or the longest timeout any account has configured, whichever is greater.
That second cutoff is deliberately generous. It is also one cutoff for every account rather than one per account. Cleanup is housekeeping, not an access control mechanism. Devise still enforces each account's own timeout on the next request. A row that outlives its account's policy grants nothing. Deleting rows early would only risk ending sessions the application still considers valid.
The logger, and what it quietly takes with it
One thing caught us off guard. The gem uses ActiveSupport's silence feature to keep session contents out of the logs, and silence only exists on ActiveSupport::Logger and its subclasses. The application ran a plain Ruby logger. Switching to ActiveSupport::Logger turned out to be a prerequisite.
The switch has a side effect that is easy to miss, because nothing breaks. ActiveSupport::Logger comes with a plain default formatter. Production, staging and UAT logs lose their timestamp and PID. You find out when you next need them. Set the formatter explicitly:
config.logger.formatter = config.log_formatter
Why an environment variable instead of a store-to-store migration
Our own analysis had proposed the textbook migration: dual-write with reads from the old store, then reads from the new store with a fallback, then removal of the cookie store. Zero downtime, nobody logged out.
We shipped a single environment variable instead. Flipping USE_DATABASE_SESSION_STORE switches the initializer between the two stores. Everyone logs in once more.
This project moves carefully and slowly. The real cost of dual-write is not writing it but how long the transitional state lives. Here two parallel sources of truth about a session would have coexisted for months. Every question about a session would have had two possible answers for all that time. That is awkward when debugging and unhelpful during a security review, because it is hard to demonstrate that sessions are invalidated while a second code path is still in play.
A one-time logout is also cheap in this application. It is a daily-use work tool and users log in regularly. There are no long forms holding unsaved state. Data updates continuously over Action Cable. A store switch destroys no work in progress, and the cost is one login screen.
The flag also gives us control that a phased migration does not. We could enable the new store on staging, leave it running under observation, and switch it off with one setting. A dual-write phase does not roll back that way. Rolling it back is another deploy carrying another change to session handling.
What about the Rails 8 authentication generator?
Rails 8 ships an authentication generator. It is not a session store. bin/rails generate authentication creates a Session model of your own, a token stored in a signed, HTTP-only cookie, and a Current object built on ActiveSupport::CurrentAttributes. Each login creates a session record with IP address and user agent. Logout destroys the record and clears the cookie. Meanwhile session[...] keeps working through whatever store is configured, usually the cookie store, carrying the flash and the CSRF token.
That raises a question about our own choice. Session lifetime in this application already lives in application logic, as a per-account policy. Would rolling our own Session model have beaten adopting the gem?
The case for it is real. You own the lifecycle. Per-account expiry stops being something you work around and becomes something you express directly. You are not bound to the gem's schema, its serializer or its interface expectations. There is no find_by_session_id contract and no logger prerequisite. The cookie store stays where it is genuinely good, holding a flash message and a CSRF token.
The case against it is that session[...] keeps working unchanged with the gem. Every place in the application that reads or writes the session, Devise and Warden included, needed no attention at all. Rolling our own would have meant writing and owning the authentication path rather than the storage path, on an application whose auth stack was already in production and not in scope.
For a production codebase on Devise with an audit finding to close, swapping the storage layer underneath an untouched authentication layer was the smaller and more reversible change. Same principle as the flag: add a path next to the existing one instead of rebuilding the one that works.
Summary
Same captured cookie, same curl, after logout:
HTTP/1.1 302 Found
Location: https://app.example.com/users/sign_in
Logout invalidates now. So does the cleanup worker, on schedule. Widening an account's timeout no longer brings anything back, because the rows are gone. Sessions are rows, so "who is logged in" is a query.
Two areas stay outside what this change covers. Where authentication goes through an external identity provider, part of the session lifecycle belongs to that provider rather than to the application. Logout initiated on their side has to propagate to the sessions table. That path needs verifying. The other area is Action Cable, which authenticates over its own path, independent of the Rails session. Moving to a database-backed store did not extend to it automatically. Reviewing that path and grounding connection authorization in the session record is the next stage.