Refresh Rotation, Reuse Detection and Logout
The endpoint that makes the token version safe: rotate on every use, revoke the family on replay.
The refresh endpoint is where the security of the token version actually lives. The access token is short and disposable; the refresh token is the thing worth stealing, so it gets three defences.
1. Rotation
Every successful refresh invalidates the presented token and issues a new one. A stolen refresh token is useful only until the real client refreshes next — usually minutes.
2. Reuse detection
Because tokens rotate, presenting one that was already used means a copy exists. You cannot tell whether the copy is in the attacker's hands or the victim's, so you revoke the whole family — every token descended from that login — and force a fresh sign-in. One user is inconvenienced; a thief is fully evicted.
3. Absolute family expiry
Rotation alone would let a session live forever, one hop at a time. Each family carries a hard deadline copied forward on every rotation, so 30 days after the original login the chain dies regardless of activity.
Logout must revoke the family
Clearing the cookie is not logout — it tells one browser to forget a token that still works. Delete the family server-side first. Note the honest limitation: the current access token remains valid until it expires. With a 10-minute lifetime, that is your worst case; if you need better, add a token denylist keyed on jti.
Store hashes, not tokens
The refresh table holds SHA-256 hashes. A leaked database backup then contains nothing anyone can present.
Example
// The heart of it: a token that was already used means a copy is in circulation
if (row.usedAt) {
await revokeFamily(row.familyId); // evict everyone, including the thief
clearRefreshCookie(res);
return res.status(401).json({ error: 'token_reuse_detected' });
}When to use it
- A refresh token copied from a shared machine is detected the next time the legitimate user refreshes, and both sessions are terminated.
- A 30-day family cap ensures a quietly rotated stolen token cannot keep a session alive indefinitely.
- A logout deletes the refresh family server-side, so the token cannot be replayed even if it was captured in transit.
More examples
The refresh and logout endpoints
familyExpiresAt is passed through unchanged on every rotation. Recomputing it there would silently turn the absolute cap into a sliding one.
import { randomUUID } from 'crypto';
function clearRefreshCookie(res) {
res.clearCookie('rt', {
path: '/auth/refresh', secure: true, sameSite: 'none', partitioned: true,
});
}
function revokeFamily(familyId) {
for (const [hash, row] of refreshTokens) {
if (row.familyId === familyId) refreshTokens.set(hash, { ...row, revokedAt: new Date() });
}
}
app.post('/auth/refresh', async (req, res) => {
const presented = req.cookies.rt;
if (!presented) return res.status(401).json({ error: 'no_refresh_token' });
const row = refreshTokens.get(sha256(presented));
if (!row) {
clearRefreshCookie(res);
return res.status(401).json({ error: 'invalid_refresh_token' });
}
// --- reuse detection: this token was already exchanged ---
if (row.usedAt) {
revokeFamily(row.familyId);
clearRefreshCookie(res);
return res.status(401).json({ error: 'token_reuse_detected' });
}
if (row.revokedAt || row.familyExpiresAt < new Date()) {
clearRefreshCookie(res);
return res.status(401).json({ error: 'refresh_token_expired' });
}
// --- rotate: spend the old one, mint the next in the same family ---
refreshTokens.set(sha256(presented), { ...row, usedAt: new Date() });
const next = issueRefreshToken(row.userId, row.familyId, row.familyExpiresAt);
res.cookie('rt', next, REFRESH_COOKIE_OPTIONS);
res.json({ accessToken: issueAccessToken(row.userId), expiresIn: 600 });
});
app.post('/auth/logout', (req, res) => {
const presented = req.cookies.rt;
const row = presented && refreshTokens.get(sha256(presented));
// Server side FIRST — this is what actually revokes access.
if (row) revokeFamily(row.familyId);
clearRefreshCookie(res);
// Honest note: the current access token stays valid for up to 10 more minutes.
res.status(204).end();
});
// "Sign out everywhere" — every family belonging to the user
app.post('/auth/logout-all', bearerAuth, (req, res) => {
for (const [hash, row] of refreshTokens) {
if (row.userId === req.user.id) refreshTokens.set(hash, { ...row, revokedAt: new Date() });
}
clearRefreshCookie(res);
res.status(204).end();
});Watching reuse detection fire
Step 4 surprises people, and it should not: forcing one extra login on a real user is a small price for making a stolen token unusable within minutes.
# 1. Log in and capture the refresh token as an "attacker" would
curl -s -c jar.txt -X POST https://dfg.local:8443/auth/login \
-H 'Content-Type: application/json' -H 'Origin: https://abc.local:5173' \
-d '{"email":"[email protected]","password":"correct horse battery"}' \
--cacert ../rootCA.pem > /dev/null
STOLEN=$(grep rt jar.txt | awk '{print $7}')
echo "stolen: $STOLEN"
# 2. The legitimate client refreshes — RT1 is spent, RT2 is issued
curl -s -b jar.txt -c jar.txt -X POST https://dfg.local:8443/auth/refresh \
-H 'Origin: https://abc.local:5173' --cacert ../rootCA.pem | jq -r .accessToken \
| cut -c1-24
# 3. The attacker tries the copy they took earlier
curl -s -X POST https://dfg.local:8443/auth/refresh \
-H "Cookie: rt=$STOLEN" -H 'Origin: https://abc.local:5173' \
--cacert ../rootCA.pem | jq
# { "error": "token_reuse_detected" }
# 4. ...and the legitimate client is now locked out too, deliberately
curl -s -b jar.txt -X POST https://dfg.local:8443/auth/refresh \
-H 'Origin: https://abc.local:5173' --cacert ../rootCA.pem | jq
# { "error": "refresh_token_expired" } ← family revoked
# Both parties must sign in again. That is the correct outcome: the server
# cannot tell which of the two is the thief, so it trusts neither.
Discussion