Refresh-Token Rotation and Replay Detection, Explained
Access tokens are supposed to be short-lived, so something has to keep a user logged in without making them re-enter a password every fifteen minutes. That something is the refresh token — and because it's long-lived, it's the single most valuable thing an attacker can steal from your auth system. A stolen access token is a problem for a few minutes. A stolen refresh token is a problem for weeks.
Refresh-token rotation is the standard defense, and the clever part isn't the rotation — it's what happens the moment a stolen token gets used. Below: what rotation actually does, the reuse-detection trick that turns a silent compromise into a forced re-login, why OAuth 2.1 makes it mandatory for public clients, and the race condition that trips up most homegrown implementations.
Why the refresh token is the real target
In an OAuth flow you end up holding two tokens. The access token is the one you attach to API calls; it expires fast — minutes, not hours — so a leaked one has a short blast radius. The refresh token is the long-lived credential your client trades in for a new access token when the old one expires. It lives for days or weeks.
That asymmetry is the whole point: short access-token lifetimes mean a leak self-heals quickly. But it only works if the refresh token itself is protected, because a refresh token is a bearer credential — whoever holds it can mint fresh access tokens indefinitely. Steal it once and you have standing access until someone notices. So the question every auth system has to answer is: what happens when a refresh token leaks?
What rotation actually means
Rotation is one rule: every time a refresh token is used, it's consumed and replaced. The token endpoint hands back a new access token and a new refresh token, and the old refresh token is immediately invalidated. Refresh tokens become single-use.
That alone shrinks the window. A refresh token an attacker copied is only good until the next time the legitimate client refreshes — after that, the stolen copy is dead. But "until the next refresh" can still be a while, and rotation by itself doesn't tell you a theft happened. That's where reuse detection comes in.
The clever part: reuse detection
Here's the insight. If refresh tokens are single-use, then a used refresh token should never be presented again. If one is — if a token that has already been rotated away shows up at the token endpoint — exactly one of two things happened:
- The legitimate client replayed it (a bug or a network retry), or
- An attacker stole it, and now either the attacker or the real client is using a copy.
You can't tell which from the request. And critically, in the theft case, you don't know who has the current valid token — the attacker or the user. So the only safe move is to assume compromise and revoke the entire token family: the replayed token, the one that superseded it, and everything down the chain. Both parties get logged out, the user re-authenticates, and the attacker is left with dead tokens.
This is the pattern the OAuth 2.0 Security Best Current Practice (RFC 9700) recommends, and it's why OAuth 2.1 requires rotation for public clients that can't keep a secret.
A rotation chain looks like this, and the fork is the attack:
issue → RT_A ──used──> RT_B ──used──> RT_C (current, valid)
│
└── attacker replays RT_A here
→ RT_A is already superseded
→ REVOKE the whole chain (A, B, C)
→ next refresh by anyone fails → re-login
The attacker presenting the old token is what triggers the defense. Reuse detection turns a stolen refresh token from a silent, long-lived backdoor into a noisy event that ends the session for everyone and leaves an audit trail.
How NamoID does it
NamoID treats every OAuth refresh token as a link in a rotation chain. A few specifics, because the details are where this goes right or wrong:
- Refresh tokens are hashed at rest. We store only a hash of the token, never the token itself — a database leak doesn't hand over usable credentials.
- Each token row records what superseded it. When you refresh, we mint a new pair and mark the old row as superseded by the new one, linking the chain.
- A superseded or revoked token presented again is treated as replay. We revoke the whole chain forward from the replayed token, append a
security.refresh_replayaudit event (so the incident is on the permanent record, not just a log line), and reject the request. - Refresh tokens expire after a fixed lifetime even if never reused, capping the standing risk.
Conceptually, the check at the token endpoint is this:
row = lookup_refresh_token(hash(presented_token), client)
if row is None:
reject("invalid refresh_token")
if row.revoked_at or row.superseded_by_id:
# already rotated away → this is a replay
revoke_chain_from(row)
emit_event("security.refresh_replay", user=row.user_id, client=client)
reject("refresh_token superseded; chain revoked")
if row.expires_at < now():
reject("refresh_token expired")
# valid + unused → mint a new pair, mark this row superseded
return issue_tokens(user=row.user_id, supersedes=row)The security.refresh_replay event matters beyond the moment: it lands in our append-only audit store, so a security review (or a DPDP audit) can see exactly when a replay was detected, for which user and client.
The gotcha most implementations hit: concurrent refresh
Reuse detection has one sharp edge. If a client fires two refreshes at almost the same moment — a network retry, a race between two tabs, a flaky mobile connection — the second request can arrive carrying a refresh token the first request just rotated away. Strict reuse detection sees a superseded token and nukes the family, logging out a perfectly innocent user.
There's no free lunch here; you pick a trade-off:
- Strict (revoke on any reuse). Maximum security, but you must make sure clients never legitimately replay — serialize refreshes, and don't retry a refresh blindly on timeout.
- Short grace window. Allow the immediately-prior token to be reused for a few seconds and return the same new token, so a retry succeeds without weakening detection for a genuinely old token.
The right answer depends on your clients, but the failure mode to avoid is the silent one: a permissive system that never revokes on reuse has rotation in name only. If reuse never triggers anything, you've built single-use tokens with no theft detection — the appearance of security without the substance.
Sender-constraining the token is the next level up: with DPoP or mTLS, a stolen refresh token is useless without the client's private key, so theft stops mattering as much. Rotation plus reuse detection is the baseline every system should have; sender-constraining is the upgrade.
FAQ
Does rotation replace short access-token lifetimes? No — they're layers. Short access tokens limit the damage of a leaked access token; rotation limits the damage of a leaked refresh token. Keep both.
Should confidential (server-side) clients rotate too? OAuth 2.1 mandates rotation for public clients and recommends it broadly. A confidential client's secret already raises the bar, but rotation plus reuse detection still catches a stolen refresh token, so it's worth doing.
What should I store — the token or a hash? A hash. Refresh tokens are credentials; store them the way you'd store a password, so a database compromise doesn't leak working tokens.
Is revoking the whole family too aggressive? It's the point. Once a token is provably reused, you can't distinguish attacker from user, so ending every session in the family and forcing re-authentication is the only safe call.
Build on tokens that fight back
Refresh-token rotation is cheap to claim and easy to get subtly wrong — the difference between a system that rotates and a system that detects reuse is whether a stolen token ever gets noticed. Get the reuse path right and a leaked refresh token becomes a forced re-login with an audit trail instead of a quiet, standing breach.
NamoID rotates every refresh token, hashes them at rest, revokes the whole chain on replay, and records it as a security.refresh_replay event you can audit. If you're issuing tokens to public clients or AI agents, start from the OAuth 2.1 baseline that makes this the default.