Rails’ Default Authentication Has a Database Restore Problem
For a small Rails app, restoring the development database from a production backup is often practical. It is quick, it gives you real data, and Rails’ default SQLite setup makes the restore easy.
I set this up for a new Rails 8.1 app, and it became a normal part of development. Some time later, after one restore, I opened the app and found myself signed in to a user’s account I had never logged into.
At first I thought this was only a strange side effect of my development setup. The same sequence can happen during production recovery, with no development environment involved.
The cause turned out to be in the authentication code generated by Rails 8.1. It stores the session’s integer primary key in a signed cookie and looks the session up by that number:
cookies.signed.permanent[:session_id] = { value: session.id, httponly: true, same_site: :lax }
Session.find_by(id: cookies.signed[:session_id])
Here is the production-to-production case:
- We back up the database when the session id counter is at, for example,
1000. - Alice signs in. Rails inserts a row into the
sessionstable with the primary keysessions.id = 1001. Her signed cookie contains the number1001. - Production is restored from the backup. Alice’s row disappears, and the session id counter goes back to
1000. Her cookie and the application’s signing key stay the same. - Bob signs in. Rails inserts a new row with
sessions.id = 1001, but this row belongs to Bob. - Alice opens the app again. Her cookie still has a valid signature, so Rails looks up the current row with
sessions.id = 1001and signs her in as Bob.
There could be many Alices, each holding many cookies. If the app allows enough logins, attackers could collect a million valid session cookies between them before a restore, then replay them afterward as those ids are issued to other users.
The signature proves that the application issued the number. It does not prove which session that number originally identified.
A comic!
I drew the sequence as a bank story to check that I was not overstating it. I also wanted to show my family what I had spent half a day on.
What I found in GitHub
The problem was already known. #55881 described the same behavior after truncating the sessions table. Its author says the Rails security team reviewed that case and did not consider it a security vulnerability. The issue was closed as stale before I found the restore case.
When I reported the restore case, the security team took the same position: reissued primary keys were an application concern, not a Rails security issue. They directed me to the same closed issue. I still think generated authentication should prevent an old cookie from changing owners after an ordinary restore.
The original generator (#52328) used a random token. The problem appeared in #52504, which replaced it with signed_id. A signed_id looks like an opaque token, but it is based on the integer primary key, so a reused id resolves to a different session. The PR discussed removing the token column and using the primary key for lookup, but did not address id reuse.
A restore is not the only way to get there
I added new information to the closed issue: the production restore case and two other ways the same id can point to another session. None requires a forged cookie or access to the signing key.
- Production restored from a backup. This is the main case. An ordinary recovery can rewind both the
sessionstable and its id counter while pre-restore cookies remain in browsers. A later login can receive an id held by one of those cookies, which then opens the new session owner’s account. - Staging refreshed from production. The mechanism is the same, but teams often refresh staging as casually as development while keeping its signing key. Because staging may also be used by testers, contractors, or customers, an old staging cookie can put one of them into a copied production user’s account.
- One database per tenant. If tenants accept the same signed cookie but look up sessions in separate databases, replaying a cookie from one tenant to another can resolve to a different user’s session.
How to fix it
Use an independently generated random token instead of the database id to identify a session in the cookie. I returned to the generator’s original design.
Devise uses another approach. Its default
database_authenticatablesetup stores the user key together withauthenticatable_salt, derived from the bcrypt password hash, and requires both to match. Reusing an integer user id for a different user is not enough to accept the old session.
For an existing app, the smallest fix may be a random session token or an additional salt check.
Ask your coding agent
If you used the default authentication generator from Rails 8.0 or 8.1, it includes the August 9, 2024 change that introduced this behavior. Check what happens to session ids during a restore and how existing cookies are invalidated. Give this prompt to your coding agent:
Check whether this Rails app can accept an unchanged old session cookie as a different user after a database restore. Use rails/rails#55881 as context, but verify the app's actual session serialization and lookup code.
Consider production restores and staging refreshes from production. Quote the relevant code and configuration, and explain for each case whether a stored session identifier can resolve to another user while the cookie remains valid.
If the app is affected, recommend the smallest fix, but do not change the code.