Refresh Token Rotation: The Security Feature Most Auth Tutorials Skip
Most JWT tutorials end with "here's your token, store it in localStorage, you're done." That token usually lives for days or weeks. If anyone steals it — XSS, a leaked log, a compromised dependency — they are you until it expires. Here's the pattern production systems use instead, and how I implemented it in my npm package express-jwt-refresh.
The two-token system
Instead of one long-lived token, you issue two:
-
Access token — a JWT that lives ~15 minutes. The client sends it as
Authorization: Bearer <token>with every request. Short life means a stolen one is nearly useless. -
Refresh token — an opaque random string that lives ~7 days. It lives in an
httpOnly,SameSite=Laxcookie that JavaScript can't touch, so XSS can't steal it. Its only job: minting new access tokens.
The client keeps the access token in memory. When it expires, the app calls POST /refresh — the cookie goes along automatically — and gets a fresh access token. The user stays logged in for a week without retyping a password.
Rotation: use it once, lose it
Here's the part tutorials skip. Every time a refresh token is used, it's destroyed and replaced with a new one. The client always holds the newest token in the chain.
Why? Because it turns theft into a detectable event.
Reuse detection: the bank-card trick
Think about what happens when a thief steals a refresh token:
- Thief uses the stolen token → gets a fresh pair. The original token is now consumed.
- The legitimate user's app tries to refresh with that same original token.
- The server sees a token that was already consumed. A legitimate client can never do this — it always moves forward to the newest token.
That's the signal. Like a bank seeing your card used in two countries at once, the server treats it as theft: the entire token family is revoked. Every session for that user dies. The thief gets exactly one use; the real user just logs in again.
Implementation-wise, you keep tokens in "families" — every rotation stays in the same family ID. When a token arrives that isn't in the store, you check whether it belongs to a rotated family. If yes, revoke the family. Done.
// refresh handler (simplified)
const record = await store.find(sha256Hex(presentedToken));
if (!record) {
const familyId = rotatedLedger.get(hashPrefix(presentedToken));
if (familyId) await store.deleteByFamily(familyId); // suspected theft
return res.status(401).json({ error: 'Invalid refresh token.' });
}
// rotate: consume old, issue new in the same family
await store.delete(record.tokenHash);
rotatedLedger.set(hashPrefix(presentedToken), record.familyId);
const next = randomToken();
await store.save({ tokenHash: sha256(next), familyId: record.familyId });
A few details that matter:
- Store only hashes. The database holds SHA-256 of refresh tokens, never the tokens themselves — a DB leak doesn't hand out sessions.
- Refresh tokens are opaque, not JWTs. JWTs can't be revoked without a denylist; random strings can, because the store is the source of truth.
- Access tokens stay stateless. No denylist needed — just keep the TTL short.
- The ledger is bounded. In multi-process deployments, persist it in your shared store.
What I shipped
I packaged this as express-jwt-refresh — drop-in Express middleware:
const { createAuth } = require('express-jwt-refresh');
const auth = createAuth({ secret: process.env.JWT_SECRET });
app.post('/login', async (req, res) => {
const user = await verify(req.body.email, req.body.password);
res.json(await auth.login(res, { id: user.id }));
});
app.post('/refresh', auth.refresh);
app.get('/profile', auth.middleware, (req, res) => res.json(req.user));
Plus a pluggable token store (in-memory, SQLite, or your own Redis/Postgres), bcrypt helpers, a role guard, and 12 passing tests including one that simulates the theft scenario end to end.
And because I kept rebuilding this same stack, I also shipped create-bb24-app — npx create-bb24-app my-app scaffolds the whole thing (Express + SQLite + this auth + React/Vite + Docker) in one command.
Auth is one of those things that's easy to do and easy to do wrong. Rotation plus reuse detection is a small amount of code for a large amount of safety. Steal the pattern even if you don't use the package.
BUILT WITH LOVE AND CAFFEINE BY BB24
Top comments (0)