Part of the Refresh Token Design series.
I Have Shipped the Other Answer Twice
Two systems I have written and published about keep a table of spent refresh tokens and treat a repeat as an attack. One uses a used_refresh_tokens table and a queue in the client. The other hashes the token and rejects the second presentation outright.
That is the standard advice and I still think it is defensible. This post is about what happens when you take it out, which the previous post in this series did, and what that actually costs once you look at the code rather than the intention.
I described the cost at the time as a window of a few seconds. It is not a window. It is permanent, and nothing anywhere will ever tell you.
Why It Came Out
Reuse detection kills a session when a spent refresh token comes back. The intent is that only a thief can produce one, because the real client moved on to the new pair.
The real client produces one constantly.
A response is lost on a flaky connection, so the client still holds the token it sent. Two tabs refresh within the same instant. A mobile app resumes from background with a request already in flight. A retry layer does what retry layers do. In every case the client presents a token the server has already spent, and detection cannot tell any of them from theft, because there is nothing in the request that differs.
What you get is real people being signed out during ordinary use, on the days their network is worst, which is exactly when they are least able to sign in again. The false positives are not rare and they are not distinguishable. So the two pair scheme replaced it: the spent token replays instead of accusing.
That part I still stand by. The mistake was in how I accounted for what it gave up.
What I Said It Cost
The write-up said the exposure ran “from the rotation until the legitimate client’s next real API call, which in practice is seconds”.
That sentence assumes the legitimate client’s next call ejects the thief. It does not, because nothing in this design ever ejects anybody. It assumes the two parties diverge. They converge.
What It Actually Costs
The detail that decides it is one line in the replay path. Replay does not return the token that was presented. It returns whatever the row currently holds.
private fun replay(session: Session): TokenPair =
TokenPair(
accessToken = session.primaryToken,
refreshToken = session.refreshToken,
)
That is correct for the case it was written for. A client that lost a response asks again and gets the pair it should have had. It is also true for anybody else presenting a hash that matches either refresh column, and the code has no way to care which of them is asking.
Walk it with a stolen refresh token, R1.
- The attacker calls refresh with R1. The rotation window is shut, so it rotates. They receive P2 and R2. The row now holds P2 and R2 as current, P1 and R1 as previous.
- The real user is still working, on P1. That matches the previous primary hash, so their requests are served normally. Nothing looks wrong to them. No error, no re-login, no prompt.
- P1 expires after five minutes. The real user’s client refreshes with R1, the only refresh token it has ever held. R1 matches the previous refresh hash, so it replays, and the server hands them the current pair: P2 and R2.
- Both parties are now holding the identical live pair.
From there it is not a race, it is a marriage. Whichever of them rotates next receives the new pair, and the other one presents the token it holds, matches a previous hash, and is handed the same new pair on their next refresh. Every rotation by either party keeps both current. And because a rotated refresh token gets a fresh thirty day expiry, the arrangement renews itself indefinitely as long as either of them keeps using it.
The real user cannot shake the attacker off by being active. Being active is what keeps handing the attacker the next pair.
Why Nothing Notices
There is no device binding on the row, no address, no user agent, nothing recorded about who presented what. Two callers with the same bytes are the same caller as far as the server is concerned.
So there is no log line to find, no anomaly to alert on, and no state anywhere that differs between the honest case and the compromised one. A support engineer looking directly at the session row sees a perfectly healthy session.
This is the part that changed my mind about the write-up, rather than the severity itself. A cost you can describe in a design document is a cost you can weigh. This one had been written down as seconds and was actually unbounded, and the document was the only place anybody would have looked.
The Honest Accounting
Put the two failure modes side by side.
Reuse detection signs real people out during ordinary use, visibly, at a rate set by how bad their network is. Every one of those is a support ticket and a person who trusts the product slightly less.
No reuse detection means a stolen refresh token is a standing key to the account for as long as either party keeps the session warm, invisibly, with no ticket and no signal.
The first is loud and frequent and survivable. The second is silent and rare and total. Neither is acceptable, which is the actual conclusion, and the reason this series has four more posts in it.
The Answer Is Not Putting It Back
The instinct is to restore detection and accept the false logouts. That trades a silent total failure for a loud frequent one and calls it progress.
The better question is why the server cannot tell the two callers apart, because that is the real defect. Detection was only ever a proxy for identity. It tried to infer “this is a different person” from “this token came back”, which is a bad inference, and the false positives are the proof.
If the refresh token stops being a bearer secret, the inference is not needed. The server asks the caller to prove they hold something the thief could not have copied out of storage or a log, and the answer is unambiguous in a way that token ordering never is.
That is the next post: a key the browser generates, cannot export, and signs every refresh with.
It also reframes what reuse detection was for. Once a caller has to prove the device, a superseded refresh token on a bound session no longer means theft. It means an honest client that fell behind, which is what it usually meant all along.