Flow
Three ways to handle old sessions
1Sign everyone out
Simplest. Each user signs in once, and the table holds only hashes from then on.
2Mark the new rows
Keeps old sessions alive, but needs a version tag or a second column and an end date.
3Match both ways
What I shipped. A stolen hash works as a login, so hashing protects nothing.
To keep a leaked database from giving anyone a login, I hashed my session tokens. Then I shipped a lookup that took the hash as a login.
My login service gives the browser a long random token when you sign in. It used to store that token as is. Whoever shows the token is the user, so anyone who could read the table could copy a row into a cookie and be that user. So new sessions now store a SHA-256 hash of the token. The browser keeps the real token. Each request hashes the cookie and looks for the hash. A fast hash is fine here, since the token is random and there's nothing to guess.
The fallback
I didn't want to sign everyone out on deploy day, and the commit message says so. So the lookup matched two ways. It looked for the hash of the cookie, or for the cookie exactly as sent, in case the row was an old one.
WHERE stored = :hash_of_cookie OR stored = :cookie
That second half lets in any string that is already in the table. And the table is what a thief has. Send a stored hash as the cookie. The first half hashes it again and misses. The second half finds the row, and you're logged in as that user, roles and all. It works every time. As long as that branch was there, the hashed table was no safer than the old one.
I couldn't limit the fallback to old rows, either. Nothing in the code marked a row as old or new. To keep old sessions, I'd have needed a marker on new rows, like a version tag or a second column.
Finding it and pulling it out
Nobody broke in that I know of. A second batch of findings went on my security to-do list right after, rated high. It described this trick. It also said the same two-way lookup had been copied into password reset, email checks and the MFA step.
The session lookup now only looks for the hash. That went into two copies, since the customer app and the admin app each have their own. The reset, email and MFA lookups lost their raw branch too.
From the code, anyone with a session from before the hashing gets sent back to sign in once. That costs them a password and a few seconds.