DEV Community

Mukesh
Mukesh

Posted on

Refresh Token Rotation Under the Hood: How Auth0 Catches a Stolen Token Before It's Ever Replayed

Most explanations of refresh token rotation stop at "the old token gets swapped for a new one." That's true, but it skips the part that actually matters: how does the authorization server know a stolen token was used before the legitimate client tries to use it? The answer is a small piece of state most engineers never think about — a token family — and the reuse-detection algorithm built on top of it. Let's open it up.

The problem rotation alone doesn't solve

A static, long-lived refresh token is a bearer secret with an unlimited replay window. If it leaks — from a compromised mobile device, a logged network request, a misconfigured CI cache — an attacker holds a valid credential for as long as the token's TTL allows, often 30-90 days, with zero signal to the legitimate owner.

Rotation on its own only shrinks the window: every refresh call returns a new refresh token and burns the old one. That's better, but if you stop there, an attacker who grabs a token can still race the real client, use it once, and the server has no way to tell "attacker used it" apart from "the app used it."

The piece that actually catches theft is reuse detection, and it requires the server to track lineage, not just validity.

Token families: the data structure under the hood

Every refresh token belongs to a family, created at the moment the first refresh token is issued (typically at login). A family is conceptually:

family_id: f_9a2e...
lineage:   [rt_001 (used), rt_002 (used), rt_003 (active)]
subject:   user_42
client:    mobile-app-ios
status:    active | revoked
Enter fullscreen mode Exit fullscreen mode

Each rotation doesn't just generate a fresh token — it appends to that array and marks the previous token as spent, not deleted. That distinction between "spent" and "deleted" is the whole trick: the server needs to remember that rt_001 and rt_002 existed and were already consumed, so it can recognize them if they show up again.

The algorithm, step by step

On every POST /oauth/token with grant_type=refresh_token:

  1. Look up the token by its opaque value (or, in JWT-encoded refresh tokens, by the jti claim) and resolve its family.
  2. If the token matches the family's current active token — normal path. Mark it spent, generate a new token, append it to the lineage, return it. Everything continues.
  3. If the token matches a token in the family's lineage but it's already marked spent — this is the reuse signal. Somebody just replayed a token that was already exchanged once. That can only happen if two parties (the legitimate client and an attacker, or two copies of a client after a token was exfiltrated) had the same refresh token at the same time.
  4. On reuse detection: revoke the entire family, not just the offending token. Every token in that lineage, including the current active one the legitimate client is holding, becomes invalid. This forces a full re-authentication.
  5. Optionally, fire a security event (Auth0 calls this a "breached refresh token" event) so the application layer can notify the user, force a password reset, or flag the session for review.

Step 4 is the part people get wrong when they roll this themselves — the instinct is to just reject the reused token and move on. But if you don't nuke the whole family, the attacker's copy of the next token (if they got far enough to see it) or the legitimate client's still-valid token leaves a live credential in play. Killing the lineage is what turns "detected an anomaly" into "closed the hole."

A minimal implementation

Here's the reuse-check logic stripped to its core, independent of any particular auth vendor's SDK:

async function rotateRefreshToken(presentedToken) {
  const record = await db.refreshTokens.findByValue(presentedToken);
  if (!record) throw new Error('invalid_grant');

  const family = await db.tokenFamilies.findById(record.familyId);

  if (record.status === 'spent') {
    // Reuse detected — the token was valid once, but already consumed.
    await db.tokenFamilies.revokeAll(family.id);
    await auditLog.write({
      type: 'refresh_token_reuse',
      subject: family.subject,
      familyId: family.id,
    });
    throw new Error('invalid_grant'); // client must re-authenticate
  }

  if (family.status !== 'active') {
    throw new Error('invalid_grant'); // family already revoked
  }

  const newToken = crypto.randomBytes(32).toString('base64url');
  await db.refreshTokens.markSpent(record.id);
  await db.refreshTokens.create({
    value: newToken,
    familyId: family.id,
    status: 'active',
  });

  return newToken;
}
Enter fullscreen mode Exit fullscreen mode

The two lookups that make this work — "is this token spent" and "is this family still active" — are exactly the state a naive rotation implementation skips, because a naive version just deletes old tokens instead of marking them spent. Deletion throws away the evidence you need to detect the attack.

The grace-period wrinkle

Real clients aren't perfectly reliable. A mobile app on a flaky connection might send a refresh request, lose the response, and retry with the same refresh token a few seconds later — which looks identical to reuse from the server's point of view. Auth0 and most production implementations handle this with a short reuse grace period (on the order of seconds, configurable), during which a repeated request for the most-recently-spent token returns the same already-issued replacement instead of triggering revocation. Outside that window, reuse is treated as theft. Getting this window right is a genuine tuning problem: too long and you widen the attacker's usable replay margin; too short and flaky networks start locking real users out of their sessions.

Why this matters even if you're not rolling your own

If you're using Auth0, Okta, or another provider, you don't write this state machine — but you do configure it, and debugging "why did this user get logged out everywhere" tickets requires knowing it exists. If you're building your own OAuth server (say, for a service-to-service or IoT use case where a hosted IdP doesn't fit), rotation without family tracking gives you a false sense of security: you'll pass a pen test that only checks "does the old token get rejected" while remaining blind to the actual replay-race scenario that reuse detection is built to catch.

The one-line summary worth remembering: rotation limits how long a stolen token works; family-based reuse detection is what tells you that it was stolen at all.

Top comments (0)