JWT refresh token rotation is not "issue a new token and delete the old one." RFC 9700 says you keep the relationship. If a spent token shows up again, you revoke the whole family. I ran that contract in 80 lines of Node. Rotation without reuse detection still hands the session to whoever got there second.
Google autocomplete for jwt refresh token rotation currently suggests meaning, header, Stripe, example, generator, and pattern. People are searching the mechanism by name, not "auth in general."
What do interviewers actually want when they say "rotate the refresh token"?
A common whiteboard answer is: access token lives 15 minutes, refresh token lives 30 days, each refresh mints a new access token. That is a session, not rotation.
RFC 9700 section 4.14 is the current OAuth 2.0 security BCP (January 2025). For public clients it requires one of two things: sender-constrained refresh tokens (DPoP / mTLS), or rotation. Rotation is defined as:
- issue a new refresh token on every access-token refresh
- invalidate the previous one
- retain the relationship
- if a later request presents the invalidated token, revoke the active one
The last two bullets are the interview. Delete-on-use with no memory of the parent grant is just a shorter-lived secret. The attacker who wins the race keeps working. The legitimate client gets invalid_grant and you never find out why.
Auth0's public docs call the retained relationship a token family and the alarm "automatic reuse detection." Their example is the one I use in loops: legitimate client holds RT1, it leaks, legitimate client exchanges RT1 for RT2, attacker replays RT1, the family including RT2 dies, everyone re-authenticates. The log event they name is ferrt.
I am not reimplementing Auth0. I wanted a store I can run in the pad with node:assert/strict and then narrate.
Why does deleting the old token fail the theft case?
Three records. That is the whole model.
{
token, // opaque string the client holds
familyId, // the grant / lineage
generation, // 0, 1, 2...
spentAt, // null while this row is current
replacement, // the token issued when this row was spent
}
Spent is not deleted. If you Map.delete(rt1) after minting RT2, a replay of RT1 looks identical to a typo: invalid_grant. You cannot tell theft from a stale client. You also cannot revoke RT2, because you no longer know they are siblings.
The store below is dependency-free. Clock and leeway are injected so the tests do not sleep.
import { randomBytes } from "node:crypto";
const REUSE = "refresh_token_reused";
const UNKNOWN = "invalid_grant";
const FAMILY_REVOKED = "family_revoked";
function createStore({ now = () => Date.now(), leewayMs = 2000 } = {}) {
const byToken = new Map();
const families = new Map();
function mint(familyId, generation) {
const token = randomBytes(16).toString("hex");
const record = { token, familyId, generation, spentAt: null, replacement: null };
byToken.set(token, record);
const family = families.get(familyId);
family.current = token;
family.lineage.push(token);
return record;
}
function issue(userId) {
const familyId = randomBytes(8).toString("hex");
families.set(familyId, { userId, current: null, lineage: [], revoked: false });
return mint(familyId, 0);
}
function rotate(presented) {
const record = byToken.get(presented);
if (!record) return { ok: false, error: UNKNOWN };
const family = families.get(record.familyId);
if (family.revoked) return { ok: false, error: FAMILY_REVOKED };
if (record.token === family.current) {
record.spentAt = now();
const next = mint(record.familyId, record.generation + 1);
record.replacement = next.token;
return { ok: true, token: next.token, generation: next.generation };
}
const isImmediatePrevious =
record.replacement != null && record.replacement === family.current;
if (isImmediatePrevious && now() - record.spentAt <= leewayMs) {
const current = byToken.get(family.current);
return { ok: true, token: current.token, generation: current.generation, retry: true };
}
family.revoked = true;
family.current = null;
return { ok: false, error: REUSE };
}
return { issue, rotate };
}
Which five contracts do you have to show?
Contract 1: happy path. RT1 in, RT2 out, different string, generation 1.
const store = createStore();
const rt1 = store.issue("u1");
const r2 = store.rotate(rt1.token);
assert.equal(r2.ok, true);
assert.equal(r2.generation, 1);
assert.notEqual(r2.token, rt1.token);
Contract 2: rotate twice, then replay RT1. RT1 is no longer the immediate previous, so this is reuse, not a retry. The family dies. RT3, which the legitimate client is holding, must also fail.
const r3 = store.rotate(r2.token);
assert.equal(store.rotate(rt1.token).error, REUSE);
assert.equal(store.rotate(r3.token).error, FAMILY_REVOKED);
That is the RFC 9700 sentence in executable form: the server "cannot determine which party submitted the invalid refresh token, but it will revoke the active refresh token."
Contract 3: flaky network. Auth0's dashboard calls this the rotation overlap period, in seconds. A mobile client sends RT1, loses the response, retries RT1 500ms later. If you treat that as theft, you lock out real users. The demo uses 2000ms so the test can move a fake clock.
const clock = { t: 0 };
const retryStore = createStore({ now: () => clock.t, leewayMs: 2000 });
const a1 = retryStore.issue("u2");
const a2 = retryStore.rotate(a1.token);
clock.t = 500;
const retry = retryStore.rotate(a1.token);
assert.equal(retry.retry, true);
assert.equal(retry.token, a2.token); // same RT2, not a new generation
Contract 4: same retry after the window. 2501ms later, RT1 is theft. Family gone.
clock.t = 2501;
assert.equal(retryStore.rotate(a1.token).error, REUSE);
Auth0's support note on leeway is stricter than "any spent token may retry." Only the previous generation is inside the window. The generation before that still kills the family, even if the clock says you are inside N seconds. Contract 2 already covers that: RT1 after RT1→RT2→RT3 is older than previous.
Contract 5: attacker spends RT1 first. Advance the clock past leeway, then the legitimate client presents RT1. Same alarm. The attacker's RT2 dies too. I ran this with an injected clock at t=0 then t=2501. If you skip the clock and default Date.now(), the second call lands inside 2 seconds and looks like a retry. That is a real production footgun, not a test smell.
Unknown tokens stay invalid_grant. Do not revoke some other user's family because of a random string.
I ran those eight assertions (happy path, double-rotate replay, post-revoke current, retry inside window, retry after window, attacker-first plus family kill, unknown token) with node rt-rotation.mjs. All green.
What do you say out loud in the 45-minute backend round?
Something like:
I would keep refresh tokens one-time. I would not delete the spent row. Replay of a spent token is reuse detection: revoke the family, force a new grant. Retry of the immediate previous token inside a short overlap is a lost response, not theft. RFC 9700 also allows DPoP instead of rotation. I picked rotation because the pad is an in-memory map. Production still needs idle expiry.
That speech is the score. The map is evidence.
A few follow-ups I would expect, and the short answers:
| Follow-up | What I would not say |
|---|---|
| Why not put the family id inside a signed JWT refresh token? | "Because JWTs cannot be revoked." They can, you still need a denylist or a family row. Encoding the grant id in the token is an RFC 9700 implementation note, not a substitute for the store. |
| How long is access token vs refresh token? | Numbers without rotation are trivia. 5–60 minutes for access is common. Refresh idle expiry is the SHOULD in 4.14.2. Pick a number and say it is policy, not physics. |
| SPA / localStorage? | Auth0's rotation doc exists partly because ITP broke silent iframe cookie refresh. Rotation is the remediation they document. I still would not keep a refresh token in localStorage if I have an HttpOnly cookie option. |
| Stripe Idempotency-Key vs this? | Different object. Idempotency keys dedupe a POST. Token families detect two holders of one secret. I already shipped a Node idempotency guard in July; this is the sibling question. |
The backend interview that asks "JWT vs session cookies, OAuth flows, idempotency keys" is the same round. The AceRound backend-developer interview guide lists those as the REST/architecture bucket, sitting next to the 3 AM CPU production scenario. Rotation is one concrete artifact from that bucket. aceround.app — AI interview assistant is useful when the next hour is verbal walkthrough under interruption. It does not replace running the five contracts above.
What this is not
It is not "never use JWT." Access tokens can stay JWT. The refresh token in this pad version is an opaque random 16 bytes on purpose. Mixing "stateless JWT" with "I need to revoke a family" is how people invent a denylist and then claim they are stateless.
It is not a license to skip sender-constrained tokens. RFC 9700 lists rotation or DPoP/mTLS for public clients. If the interviewer is on a native app with a secure enclave, DPoP is the stronger answer and you should say so.
I would not ship this Map. Production needs a row per token, a unique constraint on the current token, and the rotate-and-revoke in one transaction. The pad version exists so you can fail the replay case in 30 seconds.
FAQ
Is reuse detection the same as token rotation?
No. Rotation makes the secret one-time. Reuse detection is the alarm when a one-time secret is presented twice. Skip the alarm and the attacker who exchanged first keeps a valid RT2.
Why kill RT2, which the legitimate user is holding?
Because the server cannot tell who is the thief. RFC 9700 is explicit: stop the attack at the cost of a fresh grant. The legitimate user signs in again. The attacker does not keep a quiet session.
How long should the overlap window be?
Auth0 configures it in seconds. Too long and you widen the replay margin. Too short and a lost mobile response locks the user out. I would start at a few seconds, log ferrt-style events, and tune from false-positive rate, not from a blog number.
Do confidential server clients still need this?
RFC 6749 already binds refresh tokens to the client that received them. 9700 still recommends rotation as an extra measure. For public clients (SPA, native) it is a MUST: rotation or sender-constrained.
If you already rotate refresh tokens in production, does a replay of generation N-2 revoke the family, or does it just return invalid_grant?
Drafted with AI assistance, then edited. The Node contracts were run locally before publishing. Rotation + reuse detection: Auth0 docs. Public-client MUST: RFC 9700 §4.14.2. Overlap / previous-generation only: Auth0 support on leeway.
Top comments (0)