To detect reuse, the server has to remember which token has already been exchanged. Each login opens a token family, each refresh swaps the token identifier inside it, and when a retired identifier shows up again the whole family is revoked. Validating the signature plus a global version number is not enough, because a copied token keeps working until it expires. The idea is simple. The hard part is doing it without logging out people who did nothing wrong, and that is where I got it wrong in production. I checked the sources on October 10, 2026.
What the standard says
First, vocabulary. The refresh token is the long-lived credential an application presents to get a new access token without asking for the password again. If someone copies it, they can mint access for weeks.
The OAuth 2.0 Security Best Current Practice (RFC 9700) requires that refresh tokens for public clients be either sender-constrained or rotated. With rotation, the server issues a new refresh token on every refresh and invalidates the previous one, but keeps the relationship between them. If an attacker and the legitimate client both use the same stolen token, one of them will present a token that was already invalidated. The text says the server cannot tell which of the two is the attacker, so it revokes the active refresh token. The cost is that the legitimate client has to get a new authorization.
RFC 6749 (core OAuth 2.0), section 6, says the server may issue a new refresh token and that, in that case, the client must discard the old one.
What I saw in production
In a project I worked on, the token was "rotating" in name only. Validation checked the signature and a per-user version number. Issuing a new pair did not invalidate the previous one, so a copy (from a backup, a compromised device, a log) kept working for 30 days, the expiry period, even after the victim had already refreshed theirs. The version number only worked as a sledgehammer, logging out every device.
The design that worked:
- each login creates a family, identified by a
sid; - each refresh swaps the
jti, the identifier of the token inside the family; - a retired
jtibeing presented again revokes that family, and only that family, so the user's other devices are not affected.
Implementing this logged out real people. There were five failure modes, all hitting users who had done nothing. The tests caught the first three. The last two slipped through and showed up in production, between August 1 and 2, 2026.
- Writing the family without waiting for the write to finish. The login responded before the family existed, the next request read "family missing", and detection failed intermittently.
- Tolerating a missing family. Treating "family not found" as an old token, accepting it and adopting it, defeats logout and detection, which work precisely by deleting the family. A missing family is only benign when the token has no
sidat all. - The honest race that looks like an attack. Two near-simultaneous refreshes: the second arrives with the
jtithat was just retired. - Fixing item 2 armed item 4. The write was done in two steps (remove the old
jti, then insert the new one), and between them the family did not exist. That was harmless while a missing family was tolerated. Once a missing family became a 401, it turned into involuntary logout. The comment that justified the gap stayed in the code, describing the old world, and nobody reread it. The fix was a single atomic operation, with no instant of absence. - A one-slot tolerance window only covers two refreshes. Keeping just the previous
jtibreaks the third simultaneous refresh: the family has already rotated twice by the time it arrives. Since reuse ends the family, every tab is logged out together. The front end calling refresh on page load made three or more simultaneous calls routine. The fix was to keep a list of retiredjtivalues with timestamps and decide by time, not by position.
One detail almost slipped by: in one of the database libraries I used, an update expressed as a list of operations requires an explicit option, otherwise the call throws. Because that write sat inside a try/catch that protects login, the error became just a warning in the log, and every session was born without a family. In the first version of the fix, 12 out of 12 refreshes returned 401. It never shipped only because the burst test existed.
Why it happens
The defense touches state shared between several requests that arrive at the same time. Any gap in time between "I deleted" and "I wrote", or any tolerance that is too small, is read by the server as an attack. The legitimate user pays, and the symptom is "the site logs me out on its own", which nobody connects to a security commit.
What to do
- Write the innocent-user tests first: several devices for the same user, a token issued before the change, and a burst of three or more simultaneous refreshes on the same cookie. Only then write the attack test.
- Accept and adopt tokens issued before the deploy, which have no
sidorjti. Without that, shipping the new version logs out your entire user base. - Write the family in one atomic operation and wait for it to finish before responding to the login.
- When ending a session by token, verify the signature. Reading the
sidfrom a token that was only decoded lets anyone forge a token and end someone else's session, and logout becomes a denial-of-service attack. - When you change a code path, reread the comments that justified its neighbors.
For details on how I build authentication and sessions in web systems, see systems and SaaS.