Refresh Token Rotation
One-Time Tokens, Theft Detection, Redis
How to issue single-use refresh tokens, detect when one is replayed, and invalidate the entire session before an attacker can act.
Refresh token rotation is the practice of issuing a new refresh token every time the old one is used, and invalidating the previous one immediately. If an attacker steals a refresh token, the next time the legitimate client rotates it, the attacker's copy stops working. If the attacker rotates first, the server detects a replay and invalidates the entire session.
Why Long-Lived Refresh Tokens Are a Problem
The Rotation Model
Every refresh token has three properties stored server-side:
token
family_id
parent_token
Node.js Implementation
const crypto = require('crypto');
const { createClient } = require('redis');
const redis = createClient();
await redis.connect();
// Issue the first refresh token at login
async function issueRefreshToken(userId) {
const familyId = crypto.randomUUID();
const token = crypto.randomBytes(32).toString('base64url');
await redis.set(
`rt:${token}`,
JSON.stringify({ userId, familyId, parentToken: null }),
{ EX: 60 * 60 * 24 * 7 } // 7 days
);
return { token, familyId };
}
// Rotate: validate old token, issue new one, kill old one
async function rotateRefreshToken(oldToken) {
const raw = await redis.get(`rt:${oldToken}`);
if (!raw) {
// Token not found — either expired or already rotated
// Check if it was a parent (replay attack signal)
await detectAndKillFamily(oldToken);
throw new Error('Invalid or replayed refresh token');
}
const { userId, familyId } = JSON.parse(raw);
const newToken = crypto.randomBytes(32).toString('base64url');
// Atomically: delete old, store new, store "was parent" marker
const pipe = redis.multi();
pipe.del(`rt:${oldToken}`);
pipe.set(`rt:${newToken}`,
JSON.stringify({ userId, familyId, parentToken: oldToken }),
{ EX: 60 * 60 * 24 * 7 }
);
// Keep a short-lived record that oldToken was legitimately rotated
pipe.set(`rt-rotated:${oldToken}`, familyId, { EX: 60 * 60 * 24 * 7 });
await pipe.exec();
return { newToken, userId, familyId };
}
// Replay detected: kill every token in the family
async function detectAndKillFamily(replayedToken) {
const familyId = await redis.get(`rt-rotated:${replayedToken}`);
if (!familyId) return; // just expired, not replayed
// Scan for all tokens in this family and delete them
// In production, maintain a family→[tokens] index for O(1) lookup
await redis.set(`family-killed:${familyId}`, '1', { EX: 60 * 60 * 24 * 7 });
console.error(`Token reuse detected — family ${familyId} invalidated`);
}
Python Implementation
import secrets, json
from uuid import uuid4
import redis
r = redis.Redis(decode_responses=True)
TTL = 60 * 60 * 24 * 7 # 7 days
def issue_refresh_token(user_id: str) -> str:
family_id = str(uuid4())
token = secrets.token_urlsafe(32)
r.setex(f"rt:{token}", TTL,
json.dumps({"user_id": user_id, "family_id": family_id}))
return token
def rotate_refresh_token(old_token: str) -> tuple[str, str]:
raw = r.get(f"rt:{old_token}")
if not raw:
_detect_and_kill_family(old_token)
raise ValueError("Invalid or replayed refresh token")
data = json.loads(raw)
user_id = data["user_id"]
family_id = data["family_id"]
new_token = secrets.token_urlsafe(32)
pipe = r.pipeline()
pipe.delete(f"rt:{old_token}")
pipe.setex(f"rt:{new_token}", TTL,
json.dumps({"user_id": user_id, "family_id": family_id}))
pipe.setex(f"rt-rotated:{old_token}", TTL, family_id)
pipe.execute()
return new_token, user_id
def _detect_and_kill_family(replayed_token: str):
family_id = r.get(f"rt-rotated:{replayed_token}")
if not family_id:
return # token just expired naturally
r.setex(f"family-killed:{family_id}", TTL, "1")
# log / alert here — this is a real security event
Storing Refresh Tokens Safely on the Client
| Storage | XSS risk | CSRF risk | Verdict |
|---|---|---|---|
| localStorage | ✗ High | ✓ None | Any XSS reads the token. Do not use for refresh tokens. |
| sessionStorage | ✗ High | ✓ None | Same risk as localStorage; cleared on tab close but XSS still reads it. |
| In-memory (JS var) | ✓ Low | ✓ None | Survives XSS only if the attack runs in a different frame. Lost on page reload — user must re-login. |
| httpOnly cookie | ✓ None | ✗ Needs mitigation | JS cannot read it. Add SameSite=Strict and a CSRF token for mutation requests. |
// Setting a refresh token cookie in Node.js (Express)
res.cookie('refresh_token', newToken, {
httpOnly: true,
secure: true, // HTTPS only
sameSite: 'strict', // no cross-site requests
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days in ms
path: '/auth/refresh', // scoped to refresh endpoint only
});
The Full Flow: Login → Rotate → Logout
Inspect Your JWT Claims
Check the exp claim on your access tokens — short expiry is the complement to refresh token rotation.
Open JWT Decoder →