auth · Lesson 7 · Node + Express

The Token Lifecycle

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 app
The one idea

Split 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."

01 The dilemma this resolves

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.

💡 The escape hatch: two tokens with different jobs Stop asking one token to be both fast and revocable. Give the fast job to a short access token (no server lookup, dies on its own) and the revocable job to a refresh token (checked against server state only when renewing — rarely). You get statelessness on the hot path and control on the cold path. See WorkOS — RFC 9700 best practices.

02 The two tokens

Access tokenRefresh token
LifetimeShort — 15–60 minLong — days to weeks
Sent…On every API requestOnly 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 radiusDangerous & long-lived → needs rotation + detection (§03–04)
Stored where?Memory / HttpOnly cookieHttpOnly 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.

03 Rotation: every refresh swaps both tokens

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.

login AT₀ + RT₀ refresh AT₁ + RT₁ refresh AT₂ + RT₂ refresh AT₃ + RT₃ RT₀ retired RT₁ retired RT₂ retired Each refresh token is valid exactly once. The "family" = this whole chain. If a RETIRED token (RT₀, RT₁…) is ever presented again → theft signal (§04)
One login = one token "family." Rotation makes every refresh token single-use, so a leaked copy is only good until the next legitimate refresh — and its reappearance is a detectable alarm.

04 Reuse detection: the clever part

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.

💡 Why revoke everything, even the good token? Because the server can't tell which holder is the attacker — it only knows two parties have tokens from one chain. Burning the whole family is the safe move: it guarantees the attacker loses access, at the cost of one extra login for the real user. Asymmetric in your favor. See Auth0 — Refresh Token Rotation · RFC 9700.

05 🎯 Your tangible win (4 min): catch a stolen token

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
🎯 The win — the theft moment, verbatim
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-authenticate
You 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."

06 Logout & revocation — the original loose end, tied off

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:

Log out (this device)

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.

Log out everywhere

Revoke all families for the user. Every device must re-authenticate. Trivial because families are server-side state.

Ban / force logout

Same move, server-initiated. Revoke the user's families; their next refresh fails.

"Active sessions" list

Each family = one session row (device, last-seen). The settings-page session list you've seen is literally this table.

⚠️ The residual window — name it honestly Between revocation and the access token's expiry, a stolen access token still works (minutes). That's the deliberate cost of statelessness. Options: shorten access-token lifetime, or for high-stakes actions check a server-side denylist / re-verify. Don't pretend the window is zero — engineer it to an acceptable size.

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
});

07 Check yourself

Q1. Why split into access + refresh tokens instead of just issuing one long-lived token?

Show answer

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?

Show answer

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?

Show answer

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.)

08 Where we go next

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.

📌 Remember this one line Two tokens: a short stateless access token (dies on its own) + a long revocable refresh token that rotates on every use. A reused (retired) refresh token = theft → revoke the whole family. That's how a token system gets both speed and revocation.

💬 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.