What
This is a nine part series on refresh token rotation, session records, and device binding, built around one claim: the JWT in your auth system is not a stateless assertion, and treating it as one is what makes the design complicated.
A JWT is three useful properties. It expires on its own, it cannot be modified without invalidating the signature, and it can carry data you put there. That is a good envelope. It is not an authority on whether the person holding it should still be let in.
The series covers rotation with a previous pair, scope tiers for a half authenticated session, why refresh token reuse detection was dropped and what that cost, binding the refresh token to a key the browser cannot export, branching a session instead of narrowing it, signing every request rather than only the refresh, and a week where two of those features quietly cancelled each other out.
The Assumption
Stateless authentication is the reason most people reach for JWTs. Verify the signature, read the claims, serve the request. No session table, no lookup, no shared store between instances. It is a real benefit and it is genuinely how the token works.
It holds right up until the first requirement that has nothing to do with the token.
Block a user. Their token is still signed, still unexpired, and still says exactly what it said an hour ago. The signature is fine. The claims are fine. The answer has to come from somewhere else.
End a session from another device. Same problem. Rotate a refresh token and detect that the old one came back. Same problem, and worse, because now you need to know which tokens have been seen.
None of those questions is answerable from a token, because all three are about something that changed after the token was minted. A signature proves the token has not been edited. It says nothing about whether the world moved.
What It Costs to Keep the Illusion
Here is the shape this takes in a real system, one I wrote and then replaced.
The token carried a globalHash claim, an account status, and an encrypted issue time, so the request path could decide from the token. Blocking a user meant a jwt_black_lists table with a row per user and a created timestamp, and the check was “does any block on this user postdate this token”. Reading that on every authenticated request was expensive, so the globalHash claim existed to skip it: a serial number stamped into every token, compared against the current one, and only if they disagreed would the table be read. Single use refresh tokens needed a second table of token hashes. The blacklist got a Redis copy on top of all of it.
Four mechanisms, all of them there to preserve the idea that the token is self sufficient. The system worked. It was also carrying a table, a cache of the table, a claim to avoid the cache, and a second table, for a property it did not actually have.
Two details from that code are worth stating plainly, because they are what convinced me the design was upside down rather than merely heavy.
The serial stamped into tokens was a field on a singleton, initialised to a random value when the process started. Not persisted, not shared. So every access token minted before a restart is refused afterwards, and two instances behind a load balancer would reject each other’s tokens permanently. It worked because there was exactly one process.
And the claim was written under one name and read under another, so the comparison never matched, so the lookup it existed to skip ran on every single request anyway. The optimisation had never fired. Nobody noticed, because the only symptom of it working correctly would have been a slightly smaller database load.
That is not a story about sloppiness. It is what happens when a mechanism exists to defend an assumption rather than to do a job. Nothing depends on it being right, so nothing tells you when it is wrong.
The Other Direction
The alternative is to stop fighting it. Read the session row on every request, and accept the cost honestly.
What you get back is large. Rotation becomes a comparison against columns you already loaded. Revocation becomes deleting a row, which takes effect on the next request rather than whenever a cache expires. Device binding becomes a public key column. Scope tiers become an enum on the row, so a session can be narrowed in place and the narrowing is true immediately, without waiting for a five minute token to age out. Reuse detection, if you want it, is two hash columns rather than a growing table.
Every one of those is a property of the row. You are reading the row anyway.
The cost is one cached read per request, backed by the database on a miss, where the cache holds no authority and every path resolves correctly with it down. That is the entire trade. It buys the removal of all four mechanisms above.
It Is Not a Session Store
This is worth separating from what people picture when they hear server side sessions. That usually means a container holding the user’s own state: their cart, their last page, whatever the framework parked in there. It needs sticky routing or replication, it grows with everything anybody puts in it, and avoiding it is a large part of why JWTs became popular in the first place.
This row holds none of that. Four token hashes, a scope, a device key, three timestamps. Nothing about the user and nothing about the application. The user table is still the user table.
Which makes it adaptive rather than accumulating. Its size is the number of sessions alive right now, not the number of accounts ever registered. A million users who are all signed out is an empty table. Signing out deletes the row, an expiry sweep takes what signing out missed, and the table drains back to nothing on its own.
Nothing in it is sticky either. The row lives in Postgres and is cached in Redis, the cache holds no authority, and any instance can answer any request.
That is the mix worth having. The transport stays stateless, and the authority is a row small enough to read on every call.
What the Token Is Then
The token stops being an assertion and becomes a lookup key that cannot be forged or held forever.
The signature proves we minted it and that nobody edited the session id inside it. The claims are a cache of what the row said at minting time, and anything that decides access is read from the row rather than from the claim, because the two diverge the moment a session is narrowed.
The expiry is the part the row cannot do, and it is doing more work than it looks like. Somebody who kept a token from a year ago cannot present it, and nothing had to remember them to refuse it. The signature check and the expiry check both happen before any lookup, which has two consequences worth having.
Junk never becomes a query. A forged or ancient token is refused in CPU, with no database and no cache touched, so an attacker cannot turn a stream of rubbish into a lookup per request.
And rows can be deleted without ceremony. Once a session’s refresh token has passed its own expiry the row is free to sweep, because no token naming it can ever verify again. A store that had to stay authoritative on its own would have to keep every identifier it ever issued, forever, in case one came back. This one does not, and that is what keeps the table tracking live sessions instead of accumulating history.
So the labour divides cleanly. The token answers whether a request is worth looking up at all. The row answers whether it should still work. That is a smaller job than “stateless authentication”, and it is a job a JWT does very well.
Stack
The system this series describes runs on Kotlin and Spring Boot with PostgreSQL and Redis, and an Angular client using WebCrypto for the device key. None of it is framework specific. The rotation table, the tier model, and the signing scheme translate to any stack that can hash a token and read a row.
This Series
- Your JWT Was Never Stateless
- Refresh Token Rotation With a Previous Pair
- Three Scope Tiers for a Half Authenticated Session
- Dropping Reuse Detection Gave Two People One Session
- Binding a Refresh Token to the Device With WebCrypto
- Branch the Session Instead of Narrowing It
- Signing Every Request, Not Just the Refresh
- The Day Two Security Features Cancelled Each Other Out
- Email Stops Being a Second Factor