Security
Part 4 of 7 · OAuth & OIDCRefresh Token Rotation & Reuse Detection
Refresh token rotation issues a new RT on every refresh and invalidates the old one; reuse detection treats a replayed ancestor as theft and revokes the whole token family. This is OAuth 2.1 / BCP guidance for public clients and pairs with short-lived access tokens and BFF storage.
- 1Gist
- 2Maps
- 3Q&A
- 4Sandbox
Voice readout needs Web Speech Synthesis in this browser.
Why rotation is the public-client default
Prefer
Rotate every refresh + family reuse detection
A stolen RT0 is a one-shot. The next honest (or attacker) use of an ancestor kills the lineage. Short access tokens stay short.
- OAuth 2.1 / RFC 9700 guidance for public clients.
- Detects theft that silent reusable refresh cannot.
- Pairs with sender-constrained tokens (DPoP / mTLS) when you can.
- BFF makes refresh single-flight so tabs do not false-alarm.
Alternative
Long-lived reusable refresh, or rotate without family revoke
Reusable RT is durable loot. Rotate-but-keep-the-new-token-on-replay lets the attacker keep RT1 while you panic about RT0.
- No rotation on a SPA → stolen refresh is durable access until manual revoke.
- Rotation without reuse detection → attacker and victim alternate unnoticed.
- Aggressive reuse revoke + flaky retries → false lockouts (need grace).
- RT in localStorage → XSS steals the long-term key.
Rotation then reuse
Sequence diagram below is the same story with an attacker replaying RT0.
- 1
Client POSTs refresh RT0
grant_type=refresh_token. AS validates family, expiry, and that RT0 is the current head (or grace child). - 2
AS marks RT0 used, mints AT1 + RT1
Client stores RT1 atomically and discards RT0. - 3
Attacker replays stolen RT0
AS sees an ancestor whose child has already been used — reuse. - 4
Revoke the family
RT1, RT2, and related access sessions die. invalid_grant for everyone in the lineage.
Overview
Refresh tokens outlive access tokens and are high-value loot. Refresh token rotation issues a new refresh token on every refresh and invalidates the old one. Reuse detection treats presentation of an already-rotated token as theft evidence and revokes the entire token family.
This is OAuth Security BCP / OAuth 2.1 guidance for public clients — and a staple senior interview topic next to PKCE and short-lived JWTs.
Refresh strategies
| Strategy | Steal window | Multi-device | Complexity | Detect theft? |
|---|---|---|---|---|
| Long-lived reusable refresh | Large | Easy (one token) | Low | No |
| Rotating refresh (one-time) | ~network race | Need family / device rows | Medium | Yes (reuse) |
| Refresh + sender-constrained (DPoP/mTLS) | Smaller | Harder to replay elsewhere | Higher | Stronger |
| Sliding session server-side (BFF cookie) | Server-controlled | Cookie/session store | Medium | Server revoke |
What fails if you choose wrong
- No rotation on SPA → stolen refresh = durable access until manual revoke.
- Rotation without reuse detection → attacker and victim alternate refreshes unnoticed.
- Aggressive reuse revoke + flaky mobile retries → false lockouts (need grace / family design).
- Refresh tokens in
localStorage→ XSS steals the long-term key (prefer BFF cookies).
Rotation protocol
- Client sends the refresh token to
/token(grant_type=refresh_token). - AS validates the token, marks it used, mints a new access token (+ a new refresh token).
- Client discards the old refresh token and stores the new one atomically.
- If an old refresh token is seen again after the lineage has moved on → reuse → revoke the family.
Sequence
- 1
Client → Auth Server
Step 1 Refresh with RT1
- 2
Auth Server → Auth Server
Step 2 Mark RT1 used, issue RT2 in the same family
- 3
Auth Server → Client
Step 3 New access token and RT2
- 4
Attacker → Auth Server
Step 4 Replay the stolen RT1
- 5
Auth Server → Auth Server
Step 5 RT1 already used - reuse detected
- 6
Auth Server → Auth Server
Step 6 Revoke the whole family, RT2 included
- 7
Client → Auth Server
Step 7 Next refresh with RT2 fails - user signs in again
- 8
Client
Failure path - two tabs refresh at once and trip reuse - use a short grace window or one refresh lock per session
Lesson map
Refresh Token Rotation & Reuse Detection
Refresh token rotation issues a new RT on every refresh and invalidates the old one; reuse detection treats a replayed ancestor as theft and revokes the whole token family. This is OAuth 2.1 / BCP guidance for public clients and pairs with short-lived access tokens and BFF storage.
Architecture. Client Ready. Auth Server Ready. Attacker Ready. Step 2 Mark RT1 used, issue RT2 in the same family Ready. Step 5 RT1 already used - reuse detected Ready. Step 6 Revoke the whole family, RT2 included Ready. Failure path - two tabs refresh at once and trip reuse - use a short grace window or one refresh lock per session Ready
Select a node to see why it exists, or an edge to see the protocol, direction, effect, and consequence.
Mermaid export
flowchart TB c["Client Ready"] as["Auth Server Ready"] x["Attacker Ready"] step_1["Step 2 Mark RT1 used, issue RT2 in the same family Ready"] step_2["Step 5 RT1 already used - reuse detected Ready"] step_3["Step 6 Revoke the whole family, RT2 included Ready"] fail["Failure path - two tabs refresh at once and trip reuse - use a short grace window or one refresh lock per session Ready"] c -->|Step 1 Refresh with RT1| as as -->|Step 2 Mark RT1 used, issue RT2 in the same family| step_1 as -->|Step 3 New access token and RT2| c x -->|Step 4 Replay the stolen RT1| as as -->|Step 5 RT1 already used - reuse detected| step_2 as -->|Step 6 Revoke the whole family, RT2 included| step_3 c -->|Step 7 Next refresh with RT2 fails - user signs in again| as c -->|Failure path - two tabs refresh at once and trip reuse - use a short grace window or one refresh lock per session| fail
The authorization server marks RT1 used, issues RT2 in the same family, and revokes that family when the stolen RT1 shows up again. Two tabs refreshing at once can trip the same reuse check.
Token families
Bind every refresh token from one login into a family_id. Reuse of any ancestor invalidates the whole chain (and often the access sessions). Store at least: jti, family_id, parent_jti, expires, revoked.
Why the whole family? The attacker may hold RT1 while the victim still holds RT0 (or the reverse). Invalidating only the presented token leaves the thief with the newer one.
Revocation menu
Rotation detects a stolen refresh. It does not magically kill an access JWT that is already in the wild. Pick a mechanism that matches the credential:
| Mechanism | Works well with | Limitation |
|---|---|---|
| Delete the server session | Cookies and BFF | Store must be up |
| Revoke the refresh token | OAuth refresh | Access JWT still valid until exp |
| Introspection | Opaque tokens | Extra round trip — see JWT vs opaque |
jti denylist | JWT | You store state until exp |
Password reset / auth_time | All of the above | UX and propagation lag |
Production recipe: access JWT TTL of about 5–10 minutes, rotating refresh with reuse detection, BFF or secure native storage, and refresh-family revoke on logout or password change. Bind refresh to a device or mTLS when you can. DPoP (sender-constrained access tokens) makes a stolen bearer less useful because the caller must also prove the client key.
Logout that only clears the browser still leaves RT1 alive. Kill the family at the authorization server. End-session / SSO logout is a separate IdP hop, covered with cookie clearing on the BFF page.
Multi-device: one family per device / session, not one family for the human. Logging out of the phone should not necessarily kill the laptop — unless that is the product rule.
Race conditions (legit double refresh)
Mobile apps and multi-tab SPAs may fire two refreshes concurrently with the same RT0. Mitigations:
- Short grace window where RT0 still returns the same RT1 already issued, as long as RT1 has not itself been used.
- Mutex per family at the AS.
- Prefer a BFF so refresh happens server-side once.
Sandbox: rotation + reuse detection (Python)
Concurrent retry of RT0 returns the same RT1. After RT1 is itself rotated, presenting RT0 kills the family — including RT2.
Press Run. Snippets must be self-contained — no network, files, or native modules.
Sandbox: client single-flight store (TypeScript)
Only one refresh in flight. Apply the result only if this tab still holds the old refresh (lost races abandon the write).
Press Run. Snippets must be self-contained — no network, files, or native modules.
Pitfalls
Victim refreshes RT0 → RT1. Attacker had copied RT0 from a log. Draw both /token calls, the family row, and who can still mint access tokens after reuse detection. Then add a double-tap from a flaky radio: should that look like theft?
Interview Q&A
What is refresh token rotation?
Answer
Every successful refresh returns a new RT and invalidates the previous one. The access token stays short-lived; the refresh token is not a reusable year-long secret.
How does reuse detection prove theft?
Answer
A legitimate client should not present an already-rotated RT once the child has been used. Doing so implies a second party copied it (or a bug — which you still treat as compromise).
Why revoke the whole family?
Answer
The attacker may hold the latest RT while the victim holds an older one (or vice versa). Only family revoke is safe. Invalidating just RT0 leaves RT1 live.
How does this interact with PKCE?
Answer
Orthogonal. PKCE binds the authorization code to the starter. Rotation protects the refresh token afterward.
SPA storage recommendation?
Answer
Prefer a BFF httpOnly cookie session. If the RT is in the browser, XSS is catastrophic for as long as the family lives.
Concurrent refresh race?
Answer
AS grace (same unused child) or client/BFF single-flight. Otherwise two tabs look like theft and lock the user out.
When still use a non-rotating refresh?
Answer
Rare confidential first-party clients with sender-constraint and a short absolute lifetime. Still discouraged for public clients; interviews expect rotation as the default.
What do you store per refresh row?
Answer
At least jti, family_id, parent, expiry, used/revoked. Optionally device, IP reputation, and last-use for step-up.
Does rotation make JWT revocation unnecessary?
Answer
It shrinks the stolen-AT window to the access TTL. It does not revoke an already-issued JWT. That is still short TTL / denylist / opaque.
How do you revoke a JWT access token early?
Answer
You mostly wait out a short TTL. Optional jti denylist until exp. Always revoke the refresh family on logout or password change so the next access token is never minted.
What does reuse detection actually signal?
Answer
An already-rotated refresh showed up again after its child was used. Treat that as theft: revoke the family, not only the presented token. A flaky retry of an unused child is grace, not theft.