Skip to content
Discovery Authentication 4 min read · Updated 6 Aug 2026

Refresh Token Rotation: Detecting Theft You Cannot Prevent

intermediate authenticationoauthsecurity

Short-lived access tokens are the standard advice, and they work: a stolen access token is useless in fifteen minutes. But something has to mint the replacements without asking the user to log in every fifteen minutes, and that something is a refresh token — long-lived, high-value, and stored on a device you do not control.

You cannot prevent it being stolen. Malware, a compromised dependency, a leaked backup, an XSS bug. What you can do is guarantee you find out.

Rotation

Every refresh returns a new refresh token, and the one just used is immediately dead:

POST /oauth/token
grant_type=refresh_token&refresh_token=RT_1

200 OK
{
  "access_token":  "AT_2",   // 15 minutes
  "refresh_token": "RT_2"    // RT_1 is now consumed
}

Refresh tokens become single-use. Which creates the signal that makes the whole scheme worthwhile:

A consumed refresh token being presented again means two parties hold it.

Without rotation, a stolen token works quietly for its entire lifetime and nothing anywhere looks unusual. With rotation, the second use is an alarm.

Revoke the family, not the token

When reuse is detected, you know a token leaked. You do not know which request came from the attacker — the legitimate client and the thief present identical credentials.

So the only safe action is to invalidate everything descended from that token:

RT_1 → RT_2 → RT_3 → RT_4       ← one family, one session

        └── RT_2 presented again after being consumed
            ⇒ revoke RT_1…RT_4, force re-authentication

Both parties are logged out. The real user re-authenticates, mildly annoyed. The attacker re-authenticates never, because they do not have the password.

Storing a family_id on every issued token makes revocation a single indexed UPDATE.

The race that looks exactly like theft

A page loads, three requests fire, all get 401, all three try to refresh with the same token. One succeeds. The other two present a now-consumed token — and your reuse detection logs a user out for the crime of opening a page.

This is the reason teams disable rotation, and it has two proper fixes:

A grace window on the server. For a few seconds after a token is consumed, presenting it returns the same newly-issued token rather than raising reuse. Genuine attacks nearly always arrive well outside a 10-second window, since the attacker has to exfiltrate and use the credential.

Serialise on the client. One in-flight refresh; concurrent callers await the same promise.

let inFlight = null;
async function refresh() {
  inFlight ??= doRefresh().finally(() => { inFlight = null; });
  return inFlight;
}

Do both. The client fix handles your own app; the grace window handles multiple tabs, mobile plus web, and clock skew.

Storage decides whether any of this matters

Rotation is a detection mechanism. It does not help if the token is trivially readable.

Web: HttpOnly, Secure, SameSite cookies. Anything in localStorage is readable by every script on the page, so one XSS — in your code, a dependency, an analytics snippet — hands over the credential. HttpOnly removes that entire class. Scope the refresh cookie with Path=/oauth/token so it is not attached to every request.

Mobile: the platform keychain. Keychain on iOS, Keystore on Android. Never SharedPreferences or UserDefaults.

Public clients: bind the token. A refresh token for a SPA or mobile app has no client secret protecting it, so bind it to something — DPoP or mTLS proves possession of a key, making a stolen token alone insufficient. See PKCE for the same principle applied to the authorization code.

Absolute lifetimes still matter

Rotation gives a sliding session — use it and it keeps living. Without a ceiling, a session survives forever, which is rarely what anyone intended.

Set both:

  • Idle timeout — refresh unused for N days, family expires. Days for consumer apps, hours for sensitive ones.
  • Absolute timeout — the family dies at a fixed age regardless of activity. Weeks, not months.

And re-authenticate for genuinely sensitive operations regardless of token freshness. A valid session is not consent to change the password on it.

What you have actually built

Not a token that cannot be stolen. That is not achievable for a credential living on someone else’s device.

You have built a session where theft produces evidence. The attacker’s first use is indistinguishable from the user’s; their second use is not. That converts an invisible, indefinite compromise into a loud, bounded one — and given you were never going to prevent the theft, converting silence into an alarm is the win that was available.

Quick answers

What is refresh token rotation?
Each time a refresh token is exchanged for a new access token, the server issues a brand new refresh token and invalidates the one just used. A refresh token is therefore single-use.
Why does rotation improve security if the token can still be stolen?
Because it makes theft detectable. If a consumed token is presented again, two parties must hold it — the legitimate client and an attacker. That signal does not exist without rotation, where a stolen token works silently until it expires.
What should happen when a used refresh token is presented again?
Revoke the entire token family, not just that token. You cannot tell which presenter is the attacker, so the safe action is to invalidate every descendant and force both parties to re-authenticate.
How do you handle parallel requests racing to refresh?
Allow a short grace window in which the immediately-previous token still returns the current token, or serialise refreshes on the client with a single in-flight promise. Without one of these, normal concurrency looks identical to theft.

References

Related Discoveries