A stolen refresh token is worth more than a stolen password. It does not expire for weeks, it is never typed anywhere the user would notice, and on most systems it can be used again and again. Rotation makes it worth almost nothing.
The token that never dies
The usual setup issues a short-lived access token and a long-lived refresh token. The access token expires in fifteen minutes; the refresh token lives for thirty days and can be exchanged for a new access token whenever the old one runs out.
The flaw is in that word whenever. If the same refresh token can be exchanged repeatedly, then whoever holds a copy has thirty days of access, and nothing in the protocol can tell the copy apart from the original. The legitimate user notices nothing, because their session keeps working too.
Rotation: one exchange each
Rotation changes the rule to one use per token. Every exchange returns a new refresh token and marks the old one spent:
const record = await tx.refreshToken.findUnique({ where: { hash } })
if (!record) throw new InvalidToken()
await tx.refreshToken.update({ where: { id: record.id }, data: { usedAt: new Date() } })
const next = await tx.refreshToken.create({ data: { familyId: record.familyId, hash: newHash } })
The window shrinks from thirty days to the gap between one refresh and the next — typically minutes. A token copied on Monday is already spent by Tuesday.
Reuse is the signal
Rotation alone raises the cost. What makes it genuinely useful is what a second use of a spent token tells you.
There are only two ways it can happen. The client raced itself or retried after a dropped response — or somebody is replaying a token they should not have. You cannot distinguish them from the request, and that is fine, because the safe response to both is the same.
Every token issued from one login shares a familyId. On reuse, revoke the family:
if (record.usedAt) {
await tx.refreshToken.updateMany({
where: { familyId: record.familyId, revokedAt: null },
data: { revokedAt: new Date() },
})
throw new TokenReuseDetected()
}
The attacker loses the session. So does the legitimate user, who logs in again and is mildly annoyed. That trade is worth making every time.
Store hashes, not tokens
The refresh token table is a list of long-lived credentials. Treat it the way you treat passwords: store sha256(token), look up by hash, and let a database dump be worthless.
Unlike a password hash, this one does not need to be slow. Refresh tokens are high-entropy random values, not guessable phrases, so bcrypt buys nothing here and costs latency on every refresh.
Where the token lives
None of this helps if the token sits somewhere a script can read it. httpOnly and Secure keep it out of JavaScript; SameSite=Strict keeps it off cross-site requests. Scope the cookie to the refresh endpoint so it is not sent with every API call — a credential that travels less is exposed less.
The awkward part: races
A client that fires two refreshes at once will legitimately trip reuse detection. Two answers, and the second is better than it looks.
The first is a short grace window: accept a spent token for a few seconds and return the token that replaced it. It removes the false positives and gives an attacker a few seconds of overlap.
The second is to not race in the first place — a single-flight lock in the client so concurrent 401s wait on one refresh. I would take this one. It fixes the cause rather than widening the rule, and it removes an exception that would otherwise need defending forever.
What this does not solve
Rotation limits the damage from a leaked refresh token. It does nothing about a leaked access token during its lifetime, which is the argument for keeping that lifetime short. It does nothing about a compromised device, where the attacker simply rotates alongside the user. And it does nothing about phishing, which hands over the whole session honestly.
It is one control, not a strategy. It is, however, the one that turns a month-long compromise into a minutes-long one, for about fifty lines of code.