Refresh Token Rotation and Reuse Detection That Actually Catches Theft
For developers building OAuth/OIDC-style session systems, this guide explains how to design refresh token rotation so replay of a stolen refresh token is detected instead of silently extending a session. It covers the token family model, the database/state you need, the exact server-side decision logic, and the operational trade-offs that matter in production.
TL;DR — If you rotate refresh tokens but do not track token lineage and invalidate the whole token family on reuse, a stolen old refresh token can still tell you something important happened and you will miss it. The key design is: issue one-time-use refresh tokens, store family and parent/child state server-side, and when an already-used token appears again, revoke the active descendant(s) and force re-authentication. Reading time: ~7 min
What it is and where it sits
Refresh token rotation with reuse detection is the server-side pattern that turns refresh tokens from "long-lived bearer secrets" into "single-use pointers with replay alarms." The point is not just to mint a new refresh token on every /token refresh. The point is to detect that an old token was presented again after it should have been dead, which strongly suggests theft, token exfiltration, log leakage, device compromise, or a race you need to treat as suspicious.
In a typical IAM architecture, this logic lives in the authorization server or token service, backed by durable state in a database or cache+DB pair. It sits between the client app and your API layer:
- The client sends a refresh token to the token endpoint.
- The auth server validates it against server-side state.
- If valid and unused, the server marks it consumed and issues a new access token plus a new refresh token.
- If the same refresh token shows up again later, the server treats that as reuse and revokes the token family.
What it replaces: the older pattern of one long-lived refresh token per session/device that remains valid until expiry or manual revocation. That older model is simpler, but if the token is stolen, the attacker can refresh forever until expiry and you may never know.
A good mental model is a token family: one login creates a family root; each successful refresh creates the next child in the chain.
View as text
[Client App] ---- 1. POST /token grant=refresh_token ----> [Auth Server]
[Client App] <---- 2. access_token + new refresh_token ---- [Auth Server]
|
| 3. write token family state
v
[Token Store]
^
| 4. lookup parent/used/revoked/family
[Attacker] ---- 5. replay old refresh token ----> [Auth Server]
[Attacker] <---- 6. invalid_grant; family revoked ---- [Auth Server]
|
| 7. mark active descendant revoked
v
[Token Store]Where it lives in request flow:
- Browser SPA or mobile app stores the current refresh token.
- Backend-for-frontend or direct client calls the token endpoint.
- Authorization server owns rotation state. Do not push reuse detection into APIs; APIs usually only see access tokens and are too late in the flow.
- Resource servers may optionally consult revocation/introspection for access tokens, but the critical decision is at refresh time.
How it actually works
The mechanism is simple but unforgiving: every refresh token must be uniquely identifiable, single-use, and tied to a family record.
Minimal state model
For each refresh token, store at least:
jtior opaque token IDfamily_idparent_jtinullable for the rootsubject/client_id/session_id/device_idissued_at,expires_atconsumed_atnullablerevoked_atnullablereplaced_by_jtinullable- optional
seen_ip,seen_user_agentfor audit only, not primary security logic
Do not rely on self-contained JWT refresh tokens alone unless you still persist their IDs. Reuse detection requires server-side memory of prior use.
End-to-end example
Assume one mobile app session for user u123, client mobile-app, family fam_9f2....
-
User signs in. Auth server issues:
- access token
at_1valid 15 minutes - refresh token
rt_1valid 30 days - DB row:
jti=rt_1,family_id=fam_9f2,consumed_at=NULL,revoked_at=NULL
- access token
-
Fifteen minutes later, the app refreshes with
rt_1.Server transaction does all of this atomically:
SELECT ... FOR UPDATErow forrt_1- verify not expired, not revoked,
consumed_at IS NULL - set
consumed_at=now()andreplaced_by_jti=rt_2 - insert row for
rt_2in samefamily_id - return
at_2andrt_2
-
Attacker has a stolen copy of
rt_1from device logs, proxy logs, malware, or app storage leakage. They try to use it after the legitimate app already rotated. -
Server receives
rt_1again.It looks up
rt_1and seesconsumed_atis already set. That is the key signal. At that point, do not merely rejectrt_1. Revoke the family or at least the currently active branch:- set
revoked_at=now()on all unexpired, not-yet-revoked tokens infamily_id=fam_9f2 - optionally mark the session/device as high risk
- return OAuth-style error
invalid_grant
- set
-
Legitimate app later presents
rt_2.Because family
fam_9f2is now revoked, the server rejects it too. The user must re-authenticate. That is the cost of catching theft.
That last step is why this works: if you only reject the replayed old token but leave rt_2 valid, you have detected suspicious behavior but not contained it. The attacker may still have a newer token or may race again.
The exact decision tree
On POST /token with grant_type=refresh_token:
- Token not found ->
400 invalid_grant - Found but
expires_at < now()->400 invalid_grant - Found but
revoked_at IS NOT NULL->400 invalid_grant - Found and
consumed_at IS NULL-> consume it and rotate - Found and
consumed_at IS NOT NULL-> reuse detected; revoke family;400 invalid_grant
Typical HTTP shape:
{
"error": "invalid_grant",
"error_description": "refresh token reuse detected"
}
Typical curl output shape during diagnosis:
curl -i -X POST https://auth.example.com/oauth/token \
-H 'content-type: application/x-www-form-urlencoded' \
--data 'grant_type=refresh_token&refresh_token=rt_1&client_id=mobile-app'
HTTP/1.1 400 Bad Request
content-type: application/json
cache-control: no-store
pragma: no-cache
{"error":"invalid_grant","error_description":"refresh token reuse detected"}
If your logs only show invalid_grant with no reason code, add structured internal audit fields like reason=refresh_token_reuse family_id=... presented_jti=... so support can distinguish theft from expiry.
When to use it (and when not to)
Use it when refresh tokens are valuable enough that replay detection changes your risk posture.
| Scenario | Recommendation |
|---|---|
| Mobile app or desktop app with long-lived sessions | Use rotation + reuse detection per device/session |
| SPA using a backend-for-frontend that stores refresh tokens server-side | Use it; server-side storage makes it much easier |
| Highly regulated app, admin console, finance, healthcare | Use it and revoke whole family on reuse |
| Simple internal tool with 8-hour sessions and no refresh tokens | You probably do not need this |
| Pure server-to-server OAuth client credentials | Not applicable; no user refresh token family |
| Browser SPA storing refresh tokens in JavaScript-accessible storage | Reconsider architecture first; BFF or sender-constrained tokens may matter more |
| Very short sessions where re-login is acceptable | You may skip refresh tokens entirely |
You probably do not need this if:
- your access tokens are short-lived and users can just sign in again;
- your app is internal and session theft risk is low relative to operational complexity;
- you cannot store server-side token state and are unwilling to add it;
- you are really solving the wrong problem, such as XSS in a SPA. Rotation does not fix XSS; the attacker can steal the newest token too.
Trade-offs
Every benefit here costs something.
-
Benefit: stolen old token becomes a detectable signal
Cost: you need durable server-side state for every refresh token or at least every family step. -
Benefit: replay can trigger immediate containment
Cost: the legitimate user gets logged out when family revocation fires, including benign races. -
Benefit: limits attacker dwell time compared with static refresh tokens
Cost: more writes per refresh, more DB contention, and stricter transactional correctness. -
Benefit: better audit trail per device/session
Cost: more schema, retention policy decisions, and privacy review for metadata like IP/UA. -
Benefit: standards-aligned OAuth security posture
Cost: implementation complexity if your current stack assumes stateless JWTs everywhere.
Edge cases experienced teams ask about
-
Concurrent refreshes from the same legit client: mobile retry logic, multiple app tabs, or flaky networks can submit the same token twice. If your server is correct, one request wins and rotates; the loser sees reuse. Decide whether that loser should revoke the family immediately or whether you allow a tiny grace window keyed to the same client/session. Security-pure answer: revoke. UX-softened answer: tolerate a very short duplicate window only if you can strongly correlate it to the same client instance. That weakens theft detection.
-
Lost response after successful rotation: client sends
rt_1, server consumes it and issuesrt_2, but network drops the response. Client retries withrt_1and triggers reuse. This is the nastiest real-world failure mode. Common mitigation is idempotency on refresh requests or storing the last successful rotation result for a few seconds keyed by request ID/client instance. If you do this, scope it tightly; broad grace periods create replay gaps. -
Multi-region auth servers: if token state replication lags, the same token can be accepted twice in different regions. Use strongly consistent storage for refresh state, or pin a family/session to one region.
-
Hashed token storage: store only a hash of opaque refresh tokens at rest. Good practice, but you still need unique lookup and transactional updates.
In practice
Postgres schema and atomic rotation
CREATE TABLE oauth_refresh_tokens (
jti TEXT PRIMARY KEY,
token_hash BYTEA NOT NULL UNIQUE,
family_id TEXT NOT NULL,
parent_jti TEXT NULL REFERENCES oauth_refresh_tokens(jti),
subject TEXT NOT NULL,
client_id TEXT NOT NULL,
session_id TEXT NOT NULL,
issued_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL,
consumed_at TIMESTAMPTZ NULL,
revoked_at TIMESTAMPTZ NULL,
replaced_by_jti TEXT NULL REFERENCES oauth_refresh_tokens(jti)
);
CREATE INDEX idx_oauth_refresh_family ON oauth_refresh_tokens(family_id);
CREATE INDEX idx_oauth_refresh_session ON oauth_refresh_tokens(session_id);
This is the minimum durable state for one-time-use refresh tokens with family revocation. The gotcha: if you skip replaced_by_jti and family_id, incident response becomes much harder because you cannot cheaply find the active descendant or revoke the branch.
BEGIN;
SELECT jti, family_id, consumed_at, revoked_at, expires_at
FROM oauth_refresh_tokens
WHERE token_hash = digest($1, 'sha256')
FOR UPDATE;
-- Application logic branches on the selected row.
-- Success path:
UPDATE oauth_refresh_tokens
SET consumed_at = now(), replaced_by_jti = $2
WHERE jti = $3 AND consumed_at IS NULL AND revoked_at IS NULL;
INSERT INTO oauth_refresh_tokens (
jti, token_hash, family_id, parent_jti, subject, client_id, session_id, expires_at
) VALUES (
$2, digest($4, 'sha256'), $5, $3, $6, $7, $8, now() + interval '30 days'
);
COMMIT;
This transaction is the core of rotation. The gotcha: branch on rows under lock and check affected row counts; if your UPDATE changes 0 rows because another request consumed the token first, treat it as reuse, not as a harmless retry.
Token endpoint behavior in a Node/Express service
app.post('/oauth/token', async (req, res) => {
if (req.body.grant_type !== 'refresh_token') {
return res.status(400).json({ error: 'unsupported_grant_type' });
}
const presented = req.body.refresh_token;
const tokenHash = sha256(presented);
await db.tx(async (tx) => {
const row = await tx.oneOrNone(
`SELECT * FROM oauth_refresh_tokens WHERE token_hash = $1 FOR UPDATE`,
[tokenHash]
);
if (!row || row.revoked_at || new Date(row.expires_at) < new Date()) {
return res.status(400).json({ error: 'invalid_grant' });
}
if (row.consumed_at) {
await tx.none(
`UPDATE oauth_refresh_tokens
SET revoked_at = now()
WHERE family_id = $1 AND revoked_at IS NULL AND expires_at > now()`,
[row.family_id]
);
audit.warn('refresh_token_reuse', {
family_id: row.family_id,
presented_jti: row.jti,
subject: row.subject,
client_id: row.client_id
});
return res.status(400).json({
error: 'invalid_grant',
error_description: 'refresh token reuse detected'
});
}
const newRefresh = randomToken();
const newJti = crypto.randomUUID();
const updated = await tx.result(
`UPDATE oauth_refresh_tokens
SET consumed_at = now(), replaced_by_jti = $1
WHERE jti = $2 AND consumed_at IS NULL AND revoked_at IS NULL`,
[newJti, row.jti]
);
if (updated.rowCount !== 1) {
throw new Error('rotation_race_detected');
}
await tx.none(
`INSERT INTO oauth_refresh_tokens
(jti, token_hash, family_id, parent_jti, subject, client_id, session_id, expires_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, now() + interval '30 days')`,
[newJti, sha256(newRefresh), row.family_id, row.jti, row.subject, row.client_id, row.session_id]
);
return res.json({
access_token: mintAccessToken(row.subject, row.client_id, row.session_id),
token_type: 'Bearer',
expires_in: 900,
refresh_token: newRefresh
});
});
});
This shows the actual branch logic most teams need. The gotcha: do not log raw refresh tokens on errors or request bodies; if your reverse proxy logs POST bodies, turn that off before rollout.
⚠️ If you switch an existing production system from static refresh tokens to one-time-use rotation, old clients may enter logout loops after upgrade or on packet loss. Roll out behind a feature flag per client/app version and keep a migration path for already-issued legacy tokens until they age out.
Further reading
- OAuth 2.0 Security Best Current Practice
- RFC 6749: The OAuth 2.0 Authorization Framework
- RFC 6819: OAuth 2.0 Threat Model and Security Considerations
- OpenID Connect Core 1.0
- The "Session Management" and "Security" sections of your identity provider's official docs
This article was written by an AI system and published pending human review. Verify anything you intend to act on.
Have a project in mind?
Get an instant AI price estimate for it, or talk directly to our team.
One email a month on what we learn building with AI