Refresh Token Rotation Explained: Detecting Token Theft
How refresh token rotation works, why token families enable automatic breach detection, the reuse-detection algorithm step by step, and the edge cases that trip teams up.
Refresh tokens are long-lived by design, which makes them one of the juiciest targets in your auth system. Refresh token rotation is the technique that turns that liability into a tripwire: it doesn't just reduce the damage from a stolen refresh token — it lets you detect the theft and shut it down automatically. This article explains how.
It builds on the two-token model introduced in Session Management Best Practices; here we go deep on the rotation mechanics specifically.
The problem with static refresh tokens
The standard pattern issues a short-lived access token (minutes) and a long-lived refresh token (days or weeks). The access token is presented on every request; when it expires, the refresh token buys a new one without forcing the user to log in again.
But a static refresh token — one that stays the same across refreshes — is a bearer credential that's valid for weeks. If an attacker steals it (via malware, a leaked log, an XSS bug), they can silently mint fresh access tokens for as long as it lives, and you'd never know. You can't even tell the attacker's refreshes apart from the legitimate user's.
Rotation: a new token every time
Rotation changes one thing: every time a refresh token is used, the server issues a brand-new refresh token and invalidates the old one.
Now the refresh token is single-use. That alone shrinks the theft window — a stolen token is only good until the next legitimate refresh. But the real power is what rotation makes detectable.
Reuse detection with token families
Tie every refresh token descended from one login into a family sharing a family ID. Rotation advances a "current token" pointer within the family. The key rule:
If a refresh token that has already been rotated away is presented again, that's reuse — a strong signal that the token was stolen and either the attacker or the legitimate client is replaying an old one. Revoke the entire family immediately.
Whether the attacker refreshes first or the legitimate user does, the second party to present a superseded token triggers detection. Both are forced to re-authenticate — mildly annoying for the real user, fatal for the attacker.
The algorithm
For each refresh request:
1. Look up the presented refresh token.
2. If it doesn't exist -> reject.
3. If its family is revoked -> reject.
4. If it is NOT the current token
in its family (already used) -> REUSE: revoke whole family, reject.
5. Otherwise (valid, current):
- issue new access token
- issue new refresh token, mark it current
- mark the old token used
- return both
Storing this needs only a small amount of state per family: the current token (or its hash), a used/rotated marker, and a revoked flag. Store hashes of refresh tokens, never the raw values — a database leak then can't be replayed.
Edge cases that trip teams up
Rotation is simple until real clients get involved:
- Concurrent refreshes. Two tabs (or a retried request) can fire refreshes near-simultaneously with the same token. The second legitimately-in-flight request can look like reuse. Mitigate with a short grace window where the immediately-preceding token is still accepted, or by serializing refreshes client-side (a shared lock / single-flight).
- Network retries. A response that gets lost means the client retries with a token the server already rotated. Same grace-window mitigation applies.
- Offline/mobile clients. A client that was offline may come back holding an old token. Decide deliberately whether that forces a re-login (safer) or is tolerated within a window.
- Logout. Revoke the whole family on explicit logout, and revoke all families on password change — the session revocation discipline applies here too.
The grace window is the subtle part: too short and you punish legitimate concurrency; too long and you widen the attacker's opportunity. A few seconds is a common balance.
Getting concurrency right
The concurrency edge case deserves more than a bullet, because it is the one that generates production incidents. The scenario: a user has three tabs open, the access token expires, and all three tabs fire a refresh with the same token within the same fifty milliseconds. One wins. The other two present a token the server has already rotated — and a naive implementation reads that as theft and logs the user out of everything.
There are two fixes, and mature systems use both.
Client-side single-flight. The client should never have more than one refresh in flight. The standard shape is a module-level promise: the first caller that discovers an expired token starts the refresh and stores the promise; every subsequent caller awaits that same promise instead of starting its own.
let inFlight = null;
async function getAccessToken() {
if (!isExpired(current)) return current;
if (!inFlight) {
inFlight = refresh().finally(() => { inFlight = null; });
}
return inFlight;
}
That handles concurrency within one page. It does nothing across tabs, which is why the cross-tab version uses a BroadcastChannel or a lock in localStorage so that one tab performs the refresh and broadcasts the result. The navigator.locks API is the cleanest primitive for this where it is available.
Server-side grace window. Even with a perfect client, a lost response will cause a retry with a superseded token. So the server keeps the immediately-preceding token acceptable for a short window — commonly ten to thirty seconds — and, crucially, returns the same successor token rather than rotating again. That last detail matters: if the grace path issues a new token each time, two tabs end up holding different tokens and the family diverges. Record the successor on the old token row and replay it.
The grace window is a deliberate weakening, so bound it tightly. A ten-second window means a stolen token is replayable for ten seconds after the legitimate refresh — acceptable. A ten-minute window means an attacker who steals the token from a log has a comfortable margin, and you have effectively disabled reuse detection.
What to do when reuse fires
Revoking the family is the mechanical response. It is not the whole response, and treating it as such wastes the most valuable signal your auth system produces.
Tell the user something true. "Your session ended for security reasons — please sign in again" is better than a silent bounce to the login page, and far better than a generic error. Users who see the honest version are more likely to report it, which is how you learn about real compromises.
Record enough to investigate. A reuse event should write an audit log entry with the family ID, the timestamp of the original issuance, the IP and user agent of both the legitimate rotation and the reuse attempt, and how far apart they were. Two refreshes from the same IP a second apart is almost certainly a retry bug in your own client. Two refreshes from different continents is a compromise. You cannot tell these apart after the fact unless you captured it at the time.
Watch the aggregate. A single reuse event is noise. A spike in reuse events across many users at once is a signal — usually either a bad client release that broke single-flight, or a genuine credential-harvesting campaign. Alert on the rate, not on individual events, or the alert will be ignored within a week.
Decide what else the event invalidates. Revoking the refresh family kills future refreshes but does not kill the access token already in the attacker's hands, which stays valid until it expires. This is the real argument for keeping access token lifetimes short: the reuse detection is only as fast as your longest-lived access token. Five to fifteen minutes is the range where the tradeoff between revocation latency and refresh traffic usually lands. If you need immediate cutoff, you need a revocation check on the resource server, which means giving up some of the statelessness that made JWTs attractive — the tradeoff examined in JWTs vs opaque tokens.
Where to store the refresh token
Rotation is only as strong as the token's storage. Keep the refresh token in an HttpOnly, Secure, SameSite cookie so JavaScript can't read it and XSS can't exfiltrate it. Putting a long-lived refresh token in localStorage undercuts the entire scheme.
How this fits the bigger picture
Rotation with reuse detection is one of those defenses that quietly earns its keep the day something goes wrong. Combined with short access-token lifetimes, rate limiting on the refresh endpoint, and server-side revocation, it means a stolen refresh token buys an attacker very little and announces itself in the process.
EmbedAuth issues rotating refresh tokens with family-based reuse detection by default, and lists active sessions in the dashboard so any one of them can be revoked — the core of this behavior is built in rather than something you have to design and get right yourself. If you're implementing it from scratch, the algorithm above is the core; the edge cases are where the real engineering time goes.
Written by
Emilian GheoneaSenior Blockchain & Full-Stack Software Engineer. I build EmbedAuth — an embeddable authentication platform for SaaS — and write about the auth problems most teams hit too late.
Related articles
Authentication
"Remember This Device": Designing Trusted-Device Auth Without Lying to Users
What a device trust token actually is, why fingerprinting is the wrong primitive, how to scope MFA skipping safely, revocation and the active-devices list, and the failure modes that silently degrade your second factor into a decoration.
Jul 16, 2026 8 minReadAuthentication
Session Management Best Practices: Cookies, Tokens, and Rotation
Stateful sessions vs. stateless tokens, cookie security flags, refresh token rotation, idle and absolute timeouts, and how to revoke access when it matters.
Jun 10, 2026 7 minReadAuthentication
Designing a Password Reset Flow That Can't Be Abused
Password reset is the highest-value attack surface in most applications. A complete design — token handling, enumeration resistance, host header poisoning, session invalidation, and the failure modes that turn recovery into takeover.
Jul 10, 2026 8 minReadAuthentication
Email Verification That Actually Works (and What It Doesn't Prove)
Why email verification exists, what it genuinely proves, the four flow designs and their trade-offs, token security, deliverability, and the blocking-vs-nagging decision that quietly shapes your activation rate.
Jul 8, 2026 9 minRead