Detecting Refresh Token Reuse with Rotation
A leaked refresh token is a skeleton key: unlike a 5-minute access token, it can be replayed for days to silently mint new credentials. This page — part of the Secure Token Refresh and Rotation Patterns guide — shows how to make that key self-destruct. The mechanism is rotation with reuse detection: every refresh issues a brand-new token and invalidates its predecessor, and if an already-rotated token is ever presented again, the server treats it as theft and revokes the entire token family. This is the explicit recommendation of the OAuth 2.0 Security Best Current Practice (RFC 9700, which obsoletes the older RFC 6819 guidance) for public clients that cannot use sender-constrained tokens.
The Scenario
You issue long-lived refresh tokens (offline_access) to a SPA-backed Backend-for-Frontend or a native app. An attacker exfiltrates one refresh token — via malware, a logged URL, a compromised proxy, or an XSS payload that beat your HttpOnly cookie defenses. Now both the attacker and the legitimate client hold the same token. The first one to refresh wins a new token; the second one presents a token that has already been used. Plain rotation rejects the second request with invalid_grant, but that alone is not enough — you have not yet decided who was the thief or what else they did. Reuse detection turns that single rejected request into an incident response: it concludes the secret is compromised and tears down the whole lineage so neither party can continue.
Root Cause: Why Rotation Alone Leaks
OAuth 2.0 (RFC 6749, §1.5) defines refresh tokens as long-lived bearer credentials. A bearer credential is, by definition, usable by anyone who holds it — there is no cryptographic binding to the original client unless you add DPoP (RFC 9449) or mTLS (RFC 8705). Rotation (RFC 9700, §4.14.2) reduces the window of exposure by making each refresh token single-use, so a stolen token is only valid until the legitimate client next refreshes. But two failure modes remain:
- The attacker refreshes first. The legitimate client’s next refresh fails. Without reuse detection, you log the failure as a routine
invalid_grantand the attacker keeps a fresh, valid token. You have lost the session to the thief. - No lineage record exists. If you only store “the current valid token,” you cannot tell a replayed old token apart from a random invalid string. You need to remember that a specific token was issued, then consumed, so that re-presentation is recognizable as reuse rather than mere staleness.
The fix is to track token families (also called lineages): every refresh token records the family it belongs to, and every consumption is recorded so replay becomes detectable.
The Rotation Lineage and Reuse-Detection Branch
flowchart TD
Login["Login → issue RT1\nfamily F, jti=RT1"]:::idp --> Store1["Store: F active\nRT1 = valid"]:::store
RT1use["Client refreshes with RT1"]:::client --> Check1{"RT1 valid?"}:::idp
Check1 -- yes --> Rotate1["Mark RT1 used\nIssue RT2 in family F"]:::idp
Rotate1 --> RT2use["Client refreshes with RT2"]:::client
RT2use --> Check2{"RT2 valid?"}:::idp
Check2 -- yes --> Rotate2["Mark RT2 used\nIssue RT3"]:::idp
Replay["Attacker replays RT1\nalready marked used"]:::threat --> Check1
Check1 -- "no: already used" --> Breach["REUSE DETECTED\nRevoke family F\nAll RTs invalid"]:::threat
classDef client fill:#fff0ee,stroke:#c0392b,stroke-width:2px,color:#1a1614
classDef idp fill:#eef0ff,stroke:#2c3e8c,stroke-width:2px,color:#1a1614
classDef store fill:#fffbec,stroke:#d4840a,stroke-width:2px,color:#1a1614
classDef threat fill:#fff0ee,stroke:#922b21,stroke-width:3px,color:#1a1614
Each node in a family points back to its predecessor, forming a chain. A valid refresh only ever advances the chain by one. Any request that re-uses a consumed node is the signal that two parties hold the same secret — and the only safe response is to invalidate the whole chain.
Implementation
1. The token store schema
Track families and per-token state. A family_id groups a lineage; prev_jti records the chain; used_at marks consumption.
CREATE TABLE refresh_tokens (
jti UUID PRIMARY KEY, -- token identifier (RFC 7519 jti)
family_id UUID NOT NULL, -- the lineage this token belongs to
prev_jti UUID REFERENCES refresh_tokens(jti),
user_id UUID NOT NULL,
token_hash BYTEA NOT NULL, -- SHA-256 of the opaque secret, never plaintext
issued_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ, -- NULL until rotated; set on consumption
family_revoked BOOLEAN NOT NULL DEFAULT false
);
CREATE INDEX ON refresh_tokens (family_id);
Store only a hash of the token secret, exactly as you would a password — a database leak must not yield usable refresh tokens.
2. Issuing and rotating (TypeScript)
import { randomUUID, createHash, randomBytes } from "node:crypto";
import type { Pool } from "pg";
const hash = (secret: string) =>
createHash("sha256").update(secret).digest();
// Opaque, high-entropy secret. The jti is sent embedded so we can look it up O(1).
function mintToken(familyId: string, prevJti: string | null) {
const jti = randomUUID();
const secret = randomBytes(32).toString("base64url");
const value = `${jti}.${secret}`; // wire format: <jti>.<secret>
return { jti, value, hashed: hash(value), familyId, prevJti };
}
export async function issueInitialRefreshToken(db: Pool, userId: string, ttlSec: number) {
const familyId = randomUUID();
const t = mintToken(familyId, null);
await db.query(
`INSERT INTO refresh_tokens (jti, family_id, prev_jti, user_id, token_hash, expires_at)
VALUES ($1,$2,$3,$4,$5, now() + ($6 || ' seconds')::interval)`,
[t.jti, t.familyId, null, userId, t.hashed, ttlSec],
);
return t.value;
}
3. The rotation endpoint with reuse detection
This is the heart of the control. Run it in a serializable transaction so two concurrent presentations of the same token cannot both succeed.
export async function rotateRefreshToken(db: Pool, presented: string, ttlSec: number) {
const [jti] = presented.split(".");
const presentedHash = hash(presented);
const client = await db.connect();
try {
await client.query("BEGIN ISOLATION LEVEL SERIALIZABLE");
const { rows } = await client.query(
`SELECT jti, family_id, user_id, used_at, expires_at, family_revoked
FROM refresh_tokens WHERE jti = $1 FOR UPDATE`,
[jti],
);
const row = rows[0];
// Constant-time compare; unknown jti or hash mismatch → generic failure.
if (!row || !timingSafeEqualHash(row.token_hash, presentedHash)) {
throw new InvalidGrant("unknown_token");
}
if (row.family_revoked) throw new InvalidGrant("family_revoked");
if (row.expires_at < new Date()) throw new InvalidGrant("expired");
// *** REUSE DETECTION ***
// The token was already consumed. Two parties hold this secret → theft.
if (row.used_at !== null) {
await client.query(
`UPDATE refresh_tokens SET family_revoked = true WHERE family_id = $1`,
[row.family_id],
);
await client.query("COMMIT");
onReuseDetected({ familyId: row.family_id, userId: row.user_id, jti });
throw new InvalidGrant("token_reuse_detected");
}
// Happy path: mark consumed, mint successor in the same family.
await client.query(
`UPDATE refresh_tokens SET used_at = now() WHERE jti = $1`,
[jti],
);
const next = mintToken(row.family_id, jti);
await client.query(
`INSERT INTO refresh_tokens (jti, family_id, prev_jti, user_id, token_hash, expires_at)
VALUES ($1,$2,$3,$4,$5, now() + ($6 || ' seconds')::interval)`,
[next.jti, next.familyId, jti, row.user_id, next.hashed, ttlSec],
);
await client.query("COMMIT");
return next.value;
} catch (e) {
await client.query("ROLLBACK").catch(() => {});
throw e;
} finally {
client.release();
}
}
onReuseDetected is your incident hook — it should also kill any active server session and access tokens tied to the family (see the token revocation endpoint for invalidating still-valid access tokens) and emit a high-severity security event.
4. The replay window and concurrency
There is one benign source of “reuse”: a legitimate client whose refresh response was lost in flight (a dropped TCP connection after the server committed). The client retries with the old token, which is now marked used — and naive logic would nuke the family for an honest client. RFC 9700 (§4.14.2) acknowledges this. Two mitigations:
- A short grace/replay window. For a few seconds after consumption, accept a re-presentation of the just-rotated token only if it returns the exact same successor (idempotent retry), rather than treating it as theft. Cap this at ~10 seconds and require the successor to be unused.
- Bind the access token to the family. When the successor token is itself used, the grace window for the predecessor closes immediately, since a successful next-hop proves the client received the new token.
const REPLAY_GRACE_MS = 10_000;
function withinReplayGrace(usedAt: Date): boolean {
return Date.now() - usedAt.getTime() < REPLAY_GRACE_MS;
}
Security Implications
- Theft becomes self-limiting. Either the attacker or the victim triggers reuse on the next refresh, and the family dies. The maximum undetected lifetime of a stolen token is one refresh interval — not its full TTL.
- You cannot identify the thief, only the breach. Both parties lose access, which is correct: re-authentication (interactive login, MFA) is the only way to re-establish a trusted session. Do not try to keep “the real user” logged in based on IP or device — those are spoofable.
- Rotation is mandatory for public clients. RFC 9700 (§2.2.2) requires either sender-constrained refresh tokens or rotation with reuse detection. Confidential clients with strong client authentication may rotate optionally, but rotation is cheap insurance.
- Do not leak which failure occurred. Return a generic
invalid_grantto the client for unknown, expired, revoked, and reused tokens alike. Detailed reasons belong in your logs, not the response body, so an attacker cannot probe family state.
Prevention & Monitoring Hooks
- Alert on reuse, always. A
token_reuse_detectedevent is never routine. Page on it, attachfamily_id,user_id, IP, and user agent, and surface it to the user as a “we signed you out of all devices for security” notice. - Track family fan-out. A single family should advance linearly. Multiple active (unused) tokens in one family, or rapid alternating refreshes from different IPs, indicate parallel use even before a hard reuse event.
- Garbage-collect lineages. Delete revoked and expired families on a schedule; an append-only
refresh_auditlog preserves the forensic trail without bloating the hot table. - Cap absolute lifetime. Independent of rotation, enforce an absolute family expiry (e.g., 30 days) so an endlessly-rotated lineage still forces periodic re-authentication, satisfying SOC 2 and OWASP ASVS session-timeout controls.
Frequently Asked Questions
How is reuse detection different from plain refresh token rotation?
Plain rotation makes each refresh token single-use and rejects a second presentation with invalid_grant. Reuse detection adds a stored lineage so the server recognizes that the rejected token was previously consumed, concludes the secret is compromised, and revokes the entire token family — not just the one replayed token. Rotation shrinks the exposure window; reuse detection turns the inevitable replay into an automatic kill switch.
Won't a legitimate client trip reuse detection if its network drops mid-refresh?
It can, which is why you add a short replay grace window (around 10 seconds). Within that window, re-presenting the just-rotated token returns the same successor idempotently instead of revoking the family, as RFC 9700 §4.14.2 anticipates. Outside the window — or once the successor token has itself been used — any re-presentation is treated as theft.
What exactly should I revoke when reuse is detected?
The entire token family: every refresh token sharing the family_id, plus any server-side session and still-valid access tokens minted from that lineage. Access tokens are self-contained JWTs, so add the family or session id to a short-lived denylist checked at introspection, and force interactive re-authentication. Revoking only the replayed token leaves the attacker’s freshly-rotated token alive.
Can I avoid rotation entirely with DPoP or mTLS?
Yes — sender-constrained refresh tokens (DPoP, RFC 9449, or mTLS, RFC 8705) bind the token to the client’s key, so a stolen bearer token is useless without the private key. RFC 9700 accepts this as an alternative to rotation. In practice many public clients can’t reliably hold a key pair, so rotation with reuse detection remains the pragmatic default, and you can layer both.
Should the refresh token be a JWT or an opaque string?
Opaque. Refresh tokens are presented only to your own token endpoint, so they gain nothing from being self-describing JWTs and a JWT invites the temptation to validate it statelessly — which defeats reuse detection, since you must hit the store to check used_at anyway. Use a high-entropy random secret, store only its SHA-256 hash, and embed a jti for O(1) lookup.
Related
- Secure token refresh and rotation patterns — the parent guide covering rotation policy, sliding sessions, and BFF token storage.
- Handling OIDC token expiration gracefully — proactive renewal, concurrency-safe refresh queues, and clock-skew handling.
- OAuth 2.0 token revocation best practices — invalidating access tokens and sessions when a family is torn down.