Refresh Token Rotation
Article.
Every refresh returns a new refresh token and retires the one it replaced. A retired token coming back is a signal: two parties hold the same token, and one of them may be a thief. Or the same honest client retried. How do you build rotation so reuse detection catches a stolen token without logging out users whose tabs race or whose refresh responses get lost? Telling those cases apart is the whole job.
Access vs refresh basics are in Access and Refresh Tokens; logout and revocation in JWT Revocation and Logout.
The mechanism: a family and a chain
login -> RT1 family F, cap = login + 30 d
refresh RT1 -> RT2 RT1 marked used
refresh RT2 -> RT3 RT2 marked used
RT1 presented -> REUSE revoke every token in F,
force a new loginOne login starts one family; each refresh adds a link and marks the previous one used. A used token is never valid again, so a used token that comes back means its holder is not the one who used it — unless it is the same client retrying. The response to reuse is to burn the whole family, including the newest token, because the server cannot tell which holder is legitimate.
RFC 9700 makes this a requirement for public clients (§2.2.2): their refresh tokens MUST be sender-constrained or rotated. RFC 10017 adds two rules for browser apps: a rotated token must not outlive the one it replaced, and the family needs a maximum lifetime or an idle expiry. Confidential clients authenticate on every refresh, so for them rotation is defence in depth.
Who gets logged out
Detection needs both holders to redeem. The order decides who notices:
| Who redeems first | What happens |
|---|---|
| the attacker | the victim’s next refresh trips reuse; the family dies |
| the victim | the attacker’s replay trips reuse; the family dies |
| nobody: victim idle | the attacker rotates undetected until expiry |
Either way the attacker’s chain dies with the family, and the victim logs in again.
The idle victim is the gap. A stolen token from a laptop that was closed for the weekend gives the attacker a live chain until the family’s absolute cap or idle expiry, so those limits are the real bound on a theft, not the detection.
Rotation without reuse detection is worse than it looks. If the attacker redeems first, the victim’s next refresh simply fails: the victim is logged out and the attacker keeps a working chain. Rotation only pays off with the family revocation behind it.
The two honest reuses
Parallel refreshes
Two tabs, or two requests in one app, hit a 401 at the same moment and send the same refresh token. The first rotates it; the second presents a used token and burns the family, logging the user out for doing nothing wrong.
Fix it on the client by serializing refreshes (a shared promise in a tab, the Web Locks API across tabs; see Access and Refresh Tokens), and on the server with the grace window below.
The lost response
The mobile case, and the one that ships random logouts. The client sends RT5, the server rotates and answers with RT6, and the response is lost: the train enters a tunnel, a gateway times out. The client still holds only RT5, retries with it, and trips reuse.
No client fix exists, because the client never saw RT6. The server has to make rotation idempotent for a short window: a retry of the same parent within N seconds gets the same child back, not a new one and not a revocation.
Atomic rotation: compare-and-set
The naive version reads the row, checks it is unused, then updates it. Two replicas handling the same token at the same moment both read “unused”, both mint a child, and the family forks: two live chains, and no reuse ever fires. The check and the mark must be one statement.
The rotation function
GRACE = 30 # seconds a retried parent gets the same child back
IDLE = timedelta(days=14)
async def rotate(db, redis, presented: str) -> str | None:
"""Return the new refresh token, or None for a 401. The caller commits."""
parent, child, now = digest(presented), secrets.token_urlsafe(32), utcnow()
won = (await db.execute(
update(RefreshToken)
.where(RefreshToken.hash == parent,
RefreshToken.used_at.is_(None), # compare...
RefreshToken.revoked_at.is_(None),
RefreshToken.expires_at > now)
.values(used_at=now) # ...and set, in one statement
.returning(RefreshToken.family_id, RefreshToken.user_id,
RefreshToken.absolute_expires_at)
)).first()
if won:
db.add(RefreshToken(
hash=digest(child), parent_hash=parent, family_id=won.family_id,
user_id=won.user_id, absolute_expires_at=won.absolute_expires_at,
expires_at=min(won.absolute_expires_at, now + IDLE), # idle expiry
))
await redis.set(f"grace:{parent}", child, ex=GRACE) # before the commit
return child
if cached := await redis.get(f"grace:{parent}"): # a retry or a racing tab
return cached # (decode_responses=True)
row = await db.scalar(select(RefreshToken).where(RefreshToken.hash == parent))
if row is not None and row.used_at is not None: # used, outside the window
await db.execute(
update(RefreshToken)
.where(RefreshToken.family_id == row.family_id)
.values(revoked_at=now) # reuse: burn the family
)
return NoneWhy each piece is there
- The
UPDATE ... WHERE used_at IS NULL ... RETURNINGis the compare-and-set. In PostgreSQL a second transaction updating the same row waits for the first to commit, then re-checks the condition and matches nothing, so exactly one request wins. - The grace entry is written before the commit. The losing request is blocked until the winner commits, and by then the child is already in Redis, so the loser returns the same child instead of revoking the family.
- Only a used token outside the window revokes. An unknown token, an expired one or one from a revoked family is a plain 401.
- The cap is copied down and the idle expiry recomputed, so rotation never extends a session past its absolute cap.
The grace cache holds a live credential for N seconds; keep it in the same trust zone as the token store, or encrypt it. Without PostgreSQL, Redis gives the same compare-and-set in one command:
# SET NX succeeds for exactly one caller; everyone after it gets None
won = await redis.set(f"used:{parent}", 1, nx=True, ex=ttl_seconds)The table
CREATE TABLE refresh_tokens (
hash bytea PRIMARY KEY, -- sha256 of the token, never the token
parent_hash bytea, -- the token this one replaced
family_id uuid NOT NULL, -- one per login
user_id bigint NOT NULL,
absolute_expires_at timestamptz NOT NULL, -- the family's cap, copied down
expires_at timestamptz NOT NULL, -- idle expiry, never past the cap
used_at timestamptz, -- set once, by the compare-and-set
revoked_at timestamptz -- set for the whole family
);
CREATE INDEX ON refresh_tokens (family_id);One row per rotation; a nightly job deletes rows past their absolute cap.
parent_hash makes a family’s history readable after an incident.
When reuse fires
- Revoke the family and force a fresh login; the next refresh from either holder fails.
- Tell the user: “you were signed out because your session was used from somewhere else” is worth the support tickets it prevents.
- Log the
family_id, the client version, and any change of IP or ASN between the two redemptions. Never log the token. - Count it:
refresh_reuse_detected_total, split by inside or outside the grace window. A spike concentrated on one client version is a client bug — usually unserialized refreshes. Reuse spread thinly across many users is what theft looks like.
Providers
| Provider | Turned on | Grace for honest retries |
|---|---|---|
| Okta | per app | grace period, default 30 s, 0-60 s |
| Auth0 | per app | reuse interval, off by default |
| Keycloak | realm: Revoke Refresh Token | Refresh Token Max Reuse: a count, default 0 |
| Cognito | per app client | RetryGracePeriodSeconds, 0-60 s |
| simplejwt | ROTATE_REFRESH_TOKENS + blacklist |
none |
Auth0, on reuse outside its interval, revokes every refresh token issued since
the original, plus the grant. Cognito rotates through
GetTokensFromRefreshToken, and enabling it rules out the REFRESH_TOKEN_AUTH
flow of InitiateAuth.
simplejwt’s rotation blacklists the presented token and rejects its reuse with “Token is blacklisted”, but never revokes the child it issued, so a thief who redeems first keeps a working chain. Its check and its blacklist write are also separate queries, so two concurrent refreshes can both succeed. It is rotation without reuse detection.
Where it breaks in production
Random logouts after enabling rotation
Mobile users, flaky networks, no grace window: every lost response is a logout. The fix is the grace window, kept short — a few seconds covers a retry, and Okta caps it at a minute. The window is a deliberate hole: an attacker replaying the parent inside it receives the live child too, so keep it small and, where you can, honour it only for the same client (device id, IP or ASN).
A family that forked
Rotation implemented as read-then-update, behind a load balancer with several replicas. Under concurrent refreshes some families gain two live chains, and reuse detection never fires for them. Nothing errors, so it surfaces only in an audit or after a theft. Compare-and-set, or a row lock inside one transaction.
The token that outlived its session
A rotation path that sets each child’s expiry to “now plus 30 days” turns a 30-day session into an endless one for any active client — including an attacker’s. Copy the absolute cap down the chain; RFC 10017 forbids extending it for browser clients.
The trade not taken: sender-constrained refresh tokens
RFC 9700 accepts either rotation or sender-constraining for public clients. A DPoP-bound refresh token (RFC 9449) is useless without the client’s private key, so it can stay the same for its whole life: no family table, no races, no lost-response logouts. The cost is client key management (a non-extractable WebCrypto key in browsers, the Keychain or Keystore on mobile) and server support for DPoP proofs.
Neither stops in-page XSS: a script running in the page can call the refresh endpoint with the real client’s key or cookie. Rotation is the choice when you cannot rely on client keys; binding is the better one when you can, and the two combine.
Related
Interview angle 4
- “What is refresh token rotation, and what does it detect?” — every
refresh issues a new token and retires the old one, all within one family
per login. A retired token coming back means two parties hold it, so the
server revokes the whole family and forces a new login.
- Follow-up: “An attacker steals a refresh token and uses it first. Who loses?” — the victim’s next refresh trips reuse and the family dies, attacker’s chain included. If the victim never refreshes again, the attacker rotates undetected until the absolute or idle expiry, which is the real bound.
- “Mobile users get logged out at random after you enabled rotation.
Why?” — refresh responses lost on flaky networks: the app retries with the
old token and reuse fires. Make rotation idempotent for a few seconds: a
retry of the same parent gets the same child back.
- Follow-up: “And the same symptom on the web?” — tabs racing one refresh token. Serialize refreshes with the Web Locks API, and keep the server’s grace window as the backstop.
- “How do you make rotation safe with several API replicas?” — the check
and the mark must be one atomic step, an
UPDATE ... WHERE used_at IS NULL RETURNINGor a RedisSET NX. Read-then-update lets two replicas both rotate, and the family forks without any reuse firing. - “Why rotate at all instead of sender-constraining the token?” — RFC 9700 accepts either for public clients. DPoP binding removes the family table and the race logouts but needs client key management, and neither stops in-page XSS.