Refresh Token Rotation and the Race Condition Nobody Warns You About

·4 min read

oauth2refresh-tokenssecuritydebugging

Refresh token rotation is well-established advice: every time a refresh token is used, issue a new one and invalidate the old. Pair it with reuse detection — if an already-consumed token shows up again, revoke the entire token family.

The reasoning is sound. Refresh tokens are long-lived and high-value, and unlike access tokens you can't just shorten their lifetime to limit damage. Rotation means a stolen token is only useful until the legitimate client next refreshes, and reuse detection means the theft gets detected rather than quietly exploited.

Then you ship it and users start getting logged out at random.

The race

Reuse detection can't distinguish an attacker replaying a stolen token from your own client sending the same token twice. Both look identical: a consumed token, presented again.

And your own client will send it twice. Here's the sequence:

t=0    Access token expires.
t=1    Three API calls fire in parallel. All three get 401.
t=2    All three independently start a refresh with token R1.
t=3    Request A arrives. Server consumes R1, issues R2. 200.
t=4    Request B arrives — still carrying R1.
       Server sees R1 already consumed.
       -> REUSE DETECTED -> revoke the whole family
t=5    R2 is now dead. User is logged out.

Nobody was attacked. A page loaded three widgets at once.

This is worse than it looks because it's load-dependent. It won't reproduce on a quiet dev machine and will fire constantly on a dashboard that opens six requests on mount. You'll get reports of random logouts that nobody can reproduce.

Fix 1: single-flight on the client

The root cause is your client issuing concurrent refreshes. Collapse them: the first 401 starts a refresh, everything else waits on that same promise.

let refreshInFlight = null;

async function getAccessToken() {
  if (!isExpired(token)) return token;

  // Everyone awaits the same in-flight refresh instead of starting their own.
  if (!refreshInFlight) {
    refreshInFlight = doRefresh().finally(() => {
      refreshInFlight = null;
    });
  }
  return refreshInFlight;
}

This is the correct fix and you should do it regardless. Note the finally — clearing the promise only on success means one failed refresh wedges the client permanently.

Its limit: it's per-JavaScript-context. Two browser tabs are two contexts, each with its own refreshInFlight. For that you need either a shared lock (the Web Locks API, or a BroadcastChannel election) or the server-side fix below.

Fix 2: a grace window on the server

The server can distinguish "replayed 200ms later by my own client" from "replayed three days later by an attacker" — using time.

Keep a consumed refresh token valid for a short window (typically 10–60 seconds) after rotation. Within that window, presenting the old token returns the same newly-issued token rather than triggering revocation. Outside it, reuse detection fires as normal.

This trades a small amount of security for a large amount of reliability. An attacker who steals a token and replays it inside the grace window gets the same token the legitimate client already has — they'd need to win a sub-minute race against a client that's actively refreshing.

Most mature providers implement some version of this. If you're building your own authorization server, it's not optional.

What reuse detection should actually do

When it does fire legitimately, revoking just the presented token is not enough. The attacker holds a token from somewhere in the chain, and so does your user.

Revoke the whole family — every token descended from the original authorization grant. That means storing a family identifier on each refresh token and invalidating on that, not on the token itself. Both parties get logged out, which is correct: you can't tell which one is legitimate, and forcing re-authentication resolves it safely.

You should also log it. A reuse event outside the grace window is a genuine security signal, and it's one of the few you get for free.

Things that make it worse

  • Retry-on-401 middleware without coordination. Axios interceptors and similar will happily retry every failed request independently. That's the race, generated automatically.
  • Refreshing on a timer instead of on demand. A timer plus an on-demand refresh gives you two independent triggers racing each other.
  • Not handling refresh failure. If the refresh genuinely fails, clear the session and redirect to login. Retrying a dead refresh token in a loop turns one failure into a revocation event.
  • Storing tokens where two tabs share them without a lock. localStorage is shared across tabs; two tabs reading the same token and refreshing independently is the multi-tab version of the race.

Checking your setup

Decode your refresh and access tokens with the JWT Decoder to see the actual lifetimes you're working with — the gap between access token exp and refresh token expiry determines how often this path runs. The OAuth Flow Tester can walk the full authorization and refresh exchange so you can watch the rotation happen.

For token lifetimes and the refresh grant against AuthAction, see the OAuth2 endpoint reference.


Rotation handled correctly

AuthAction rotates refresh tokens, detects reuse, and applies a grace window for in-flight requests — so you get the security property without the logout bug.

Free, unlimited users. No credit card required.