One thread has nagged since Lesson 1: tokens are stateless, so they're fast — but you can't easily revoke one, and short-lived tokens mean logging in constantly. How do real systems keep you signed in for weeks while access dies in minutes, and actually kill a session on "log out everywhere"? The answer is a two-token design with rotation and reuse detection — and you'll watch a stolen token get caught today.
🎯 Mission: a mental model of modern auth + the ability to wire it into a real appSplit the credential in two: a short-lived access token (stateless, minutes) and a long-lived refresh token (revocable, days). The access token is fast and disposable — it just expires. The refresh token carries the revocable state: each use rotates it (new pair, old one retired), so if an old one ever reappears, that's theft — and the server revokes the entire token family. This is how you square "stateless and fast" with "revocable and safe."
From Lessons 1 & 3: a stateless JWT can't be un-signed, so it's valid until it expires. That forces a bad trade-off if you use one token. Make it long-lived → great UX, but a stolen token works for weeks and you can't revoke it. Make it short-lived → safe, but the user re-authenticates every few minutes. Neither is acceptable.
| Access token | Refresh token | |
|---|---|---|
| Lifetime | Short — 15–60 min | Long — days to weeks |
| Sent… | On every API request | Only to the /token endpoint, to renew |
| Checked how? | Verify signature — no DB lookup (stateless) | Looked up against server state (revocable) |
| If stolen… | Works until it expires (minutes) — limited blast radius | Dangerous & long-lived → needs rotation + detection (§03–04) |
| Stored where? | Memory / HttpOnly cookie | HttpOnly cookie or secure server-side store |
The access token is Lesson 3's JWT, kept deliberately short. The refresh token is usually an opaque value (like Lesson 1's session id) the server tracks — so it can revoke it. Notice the pattern: you've combined both halves of Lesson 1 — stateless for speed on the hot path, stateful for control on the cold path. The whole course converging.
A static refresh token that lasts two weeks is a fat target. Rotation shrinks the window: every time you exchange a refresh token for a new access token, you also get a new refresh token, and the old one is immediately retired. The refresh token becomes a single-use, ever-changing chain.
Rotation sets a trap. Once a refresh token is rotated away, it should never be seen again. So if the server does see a retired token from a family, there are only two explanations: a copy leaked, or the legit client and an attacker are both holding tokens from the same chain. Either way it's compromise. The response (per RFC 9700): revoke the entire family immediately. Both the thief and the real user are logged out — the user silently re-authenticates; the thief is locked out and you've logged a breach event.
A zero-dependency lab plays out the whole lifecycle — login, two rotations, a replayed stolen token, the family getting nuked, and logout.
# in your terminal (no npm install needed):
cd ~/projects/learn/public/courses/auth/practice/refresh-lab
node demo.js
rotation steady state: rt_94b6b1… → rt_3ea904… ✔ 🦹 attacker replays the retired token rt_94b6b1… 🛡️ REUSE DETECTED → entire token family revoked even your legitimate current token now fails: 🛡️ family revoked — re-authenticateYou watched rotation make a token single-use, then watched the replay of a spent token trip the alarm and revoke everything. That mechanism — invisible in normal use — is what lets a token system be both stateless-fast and revocable. Open
demo.js and trace how members vs current distinguishes "the live token" from "a retired one."Now "log out" is easy, and we can finally answer Lesson 3's "you can't revoke a JWT." You don't try to revoke the access token — you let it die on its own (it's short). You revoke the refresh token family:
Delete/flag this family. The current access token works for its last few minutes, then can't be renewed. Want it instant? Keep access tokens very short.
Revoke all families for the user. Every device must re-authenticate. Trivial because families are server-side state.
Same move, server-initiated. Revoke the user's families; their next refresh fails.
Each family = one session row (device, last-seen). The settings-page session list you've seen is literally this table.
Sketch of the Express refresh endpoint (rotation + detection), the heart of it:
app.post('/token/refresh', async (req, res) => { const presented = req.cookies.refresh_token; // HttpOnly cookie (Lesson 2) const rec = await db.refreshTokens.find(presented); if (!rec || rec.familyRevoked) return res.status(401).end(); if (rec.rotated) { // a retired token reappeared! await db.revokeFamily(rec.familyId); // reuse detection → nuke family return res.status(401).send('reuse detected'); } const next = await db.rotate(rec); // mark old rotated, mint new pair res.cookie('refresh_token', next.refresh, { httpOnly: true, secure: true, sameSite: 'lax' }); res.json({ access_token: next.access }); // new short-lived JWT });
Q1. Why split into access + refresh tokens instead of just issuing one long-lived token?
One token can't be both fast (stateless, no lookup) and revocable. The split assigns each property to a different token: the access token is short and stateless (fast hot path, limited theft window), while the refresh token holds the revocable, server-tracked state used rarely (only at renewal). You get speed where it's frequent and control where it matters. (§01–02.)
Q2. A retired refresh token is presented to the server. What does the server conclude and do, and why so aggressively?
It concludes the token was leaked (a rotated-away token should never reappear), so it revokes the entire token family — logging out both the attacker and the real user. Aggressive because the server can't tell which holder is malicious; burning the family guarantees the attacker loses access at the cost of one re-login for the user. Per RFC 9700. (§04, §05.)
Q3. After you call "log out everywhere," a stolen access token still works for a couple of minutes. Bug or expected? How would you shrink it?
Expected — that's the stateless trade-off. Revocation kills the refresh family so no new access tokens can be minted, but the existing short-lived access token isn't looked up per request, so it survives until exp. Shrink the window by lowering access-token lifetime, or for sensitive operations consult a server-side denylist / re-authenticate. (§06 warning.)
That closes the token story — you can now keep users logged in safely for weeks and revoke on demand. We've covered the two big login methods (passwords, OAuth/OIDC) and the full "stay logged in" machinery. Next we return to phase ① for the passwordless family: magic links and one-time codes (OTP) — how "no password at all" works, why a magic link is just a signed single-use token (Lessons 3 & 4 ideas again), and where they're safe vs risky. Then Lesson 9: passkeys, the phishing-proof endgame.
💬 Ask me anything — e.g. "where should the refresh token actually live for an SPA?", "show the DB schema for token families", or "how does this interact with the OIDC tokens from Lesson 6?" · Back to Lesson 6 · Lesson 3 · Resources.