Backend Systems · Session and State

Refresh Token Rotation and Replay Detection

How rotating refresh tokens limit the value of a stolen credential, why a used token presented twice means compromise, and how a grace window keeps honest clients from tripping the alarm.

A session that lasts weeks needs a credential that lasts weeks, and a credential that lasts weeks is worth stealing. The split between short-lived access tokens and long-lived refresh tokens is the standard answer: the frequently transmitted token expires in minutes, and the rarely transmitted one is used only against a single endpoint that can afford to be strict.

Rotation makes the long-lived credential disposable too. Each use consumes it and yields a successor, so a token taken from storage is valid only until the legitimate client next refreshes. Replay detection goes one step further and treats a second presentation of a consumed token as evidence that two parties hold it, which is the only reliable signal that a theft has occurred.

Why the refresh token lives under different rules

The access token is presented on every resource request, which means it passes through proxies, appears in logs if anything is careless, and is handled by code that changes often. The refresh token is presented only to the authorization server’s token endpoint, over a TLS connection, usually once per access-token lifetime. RFC 6749 keeps the two as separate credential types with separate issuance, and the separation is what makes a strict policy on the refresh path practical.

The refresh token should never be usable as an access token, and the two should not be verifiable by the same component. If the resource server can validate a refresh token, a compromise of any resource server yields long-lived credentials. Keeping refresh validation inside the authorization server also means the replay-detection state has exactly one owner.

A chain of single-use tokens

Rotation gives the refresh token a family: an identifier shared by every token derived from one initial authorization, and a generation counter that increases with each issuance. At rest the server stores the hash of the current token, the family identifier, the generation, an expiry, and a client binding. Nothing about the token itself needs to encode the family; it is a random string whose hash is the lookup key, exactly like the session identifiers described in How a Session Identifier Binds Requests to Server State.

A refresh request proceeds in four steps. The presented token is hashed and looked up. If no record exists, the request fails. If a record exists and the token is the current one, the server advances the generation: it stores the hash of a newly generated token at generation $g+1$ and marks generation $g$ as consumed. It returns the new token with the remaining family lifetime preserved, so rotation does not extend the session indefinitely. The response is sent only after the write that consumed the old token has committed.

The ordering in the last step is what makes the mechanism sound. Issuing before committing allows two successful refreshes from one token if the commit fails; committing before issuing risks consuming a token without delivering its successor, and the client is then locked out. The usual resolution is to commit and then respond, accepting that a lost response costs the client a re-authentication, or to make the operation idempotent so a retry of the same token returns the same successor for a bounded interval.

-- Consume generation g and install generation g+1 in one statement.
-- $1 family, $2 generation, $3 hash of the new token, $4 family expiry.
WITH consumed AS (
    UPDATE refresh_tokens
       SET consumed_at = now(), successor_hash = $3
     WHERE family_id = $1
       AND generation = $2
       AND consumed_at IS NULL
    RETURNING family_id, generation
)
INSERT INTO refresh_tokens (token_hash, family_id, generation, expires_at)
SELECT $3, family_id, generation + 1, $4 FROM consumed;

An insert count of zero means the token was already consumed, which is the branch where replay detection runs. Doing this as a conditional update rather than a read followed by a write removes the window in which two concurrent refreshes both see an unconsumed token and both succeed.

The second presentation is the signal

A token that has been consumed and is presented again has one of two explanations. Either the legitimate client never received its successor and is retrying, or an attacker who captured the token is using it while the legitimate client has already moved on. The two are indistinguishable from the request alone, and the security decision depends on choosing which risk to accept.

Treating every replay as compromise and revoking the whole family is the secure choice. It costs the user a re-authentication, and against a genuine attacker it ends the intruder’s access immediately, since the legitimate client’s refresh will also fail and force a clean login. Treating replays as retries is convenient and gives an attacker who captured a token before the legitimate refresh an unlimited number of attempts, which is the whole value of the attack.

The practical design distinguishes the two by timing and by idempotency. If the replay arrives within a short window after the consumption — seconds to a minute — and the request carries the same client binding, returning the same successor is safe: an attacker in that window is racing the legitimate client and gains access only if the legitimate client never refreshes again. Outside that window, the family is revoked. The window length is a security parameter, and it is bounded by the fact that the successor is already issued; returning it does not create new authority.

The window must not be implemented as “replays are always allowed for the same token”. That converts the single-use token back into a reusable one, with the added confusion that the server appears to be detecting something.

Revoking a family, and what revocation means

Family revocation deletes or flags every record in the chain and must also invalidate any access token derived from it that is still live. The second part is the difficult one: access tokens are typically self-contained and valid until expiry, so a revocation that does not reach them leaves the attacker with up to one access-token lifetime of access. That is the cost of short access tokens being cheap to issue; it is also the argument for keeping their lifetime at minutes rather than hours.

Keeping one row per generation, with superseded rows collapsed into a consumed marker, keeps the state small. The chain does not need to be retained in full: only the current generation is usable, so an older row can be trimmed to its family identifier, generation, and successor hash, and the whole family can be dropped when its absolute expiry passes. A family that has not refreshed for its full lifetime is dead by construction.

The absolute lifetime on the family is separate from the per-token lifetime. Without it, rotation renews the session forever, and a token stolen once and refreshed by the attacker maintains access indefinitely. With it, the session has a hard ceiling — commonly expressed in days or weeks — after which the user authenticates again regardless of activity. Session Fixation, Rotation, and Revocation makes the same distinction between idle and absolute timeouts for store-backed sessions.

Concurrent refreshes from one honest client

The failure mode that produces false alarms is mundane. A mobile app wakes, several queued requests find the access token expired, and each independently calls the refresh endpoint with the same refresh token. Two requests arrive together: one consumes the token, the other replays it. A browser with several tabs that share a token but not a lock produces the same pattern.

Client-side single-flight is the correct mitigation: one refresh request at a time, with the others awaiting its result and retrying with the new token. That requires the client to coordinate across tabs or threads, and the coordination fails when the app is killed mid-refresh — the new token may have been received but not persisted, leaving the client holding a consumed one. A client that persists the successor before discarding the predecessor narrows that, since a crash leaves it holding both and it can try the newer one first.

Server-side, the grace window absorbs what client coordination misses. Its size trades false-positive logouts against the window in which a stolen token is still useful to the thief. A minute is a common choice and is long enough for retries and short enough that a stolen token is unlikely to still be current.

Binding the token to the client

A refresh token in a public client is a bearer credential stored in a place the user’s device controls. Rotation limits how long a stolen copy works, but does not prevent the theft. Two mechanisms add a possession factor.

The first is client authentication for confidential clients, where the refresh request must present a client secret in addition to the token. That stops a leaked token from being used off-device, though it protects nothing if the secret leaks alongside it — which is common when both live in the same configuration store.

The second is proof of possession: the client holds a private key and signs a value derived from the request, so possession of the token alone is insufficient. DPoP, specified in RFC 9449, defines this for OAuth, binding each token to a key thumbprint so a stolen token cannot be presented by a party without the key. The cost is key management on the client, including rotation and loss recovery, which is why it is more common in high-value deployments than in general-purpose ones.

Authorization codes have an analogous binding. RFC 7636 requires a public client to prove, at the token endpoint, that it is the same client that started the authorization request, by presenting a verifier whose hash was sent earlier. Without it, an intercepted authorization code can be redeemed by anyone, and the resulting refresh token inherits the interception.

Where the design lands

Rotation plus replay detection converts a long-lived bearer credential into a chain in which any use is observable. It does not prevent theft, does not help if the attacker refreshes first and the legitimate client’s subsequent attempt is the one treated as replay — though it does then revoke the family and lock both parties out, which is the intended outcome — and does not extend to access tokens that are already issued. What it does give is a bounded lifetime for any captured token, a detectable event when two parties use the same credential, and a clean revocation point that removes an entire lineage in one operation.

The state it needs is small and per-family rather than per-request, which keeps the token endpoint’s storage proportional to active users rather than to traffic. That is the same trade the session store makes, seen from the other direction: state you are willing to keep determines which guarantees you can offer, and the guarantees you decline to offer determine how short every credential’s life must be.

References

  1. RFC 6749: The OAuth 2.0 Authorization Framework — IETF
  2. RFC 6819: OAuth 2.0 Threat Model and Security Considerations — IETF
  3. RFC 7636: Proof Key for Code Exchange by OAuth Public Clients — IETF
  4. RFC 9449: OAuth 2.0 Demonstrating Proof of Possession (DPoP) — IETF