Skip to main content
JWT Guide

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.

10 min read·Updated May 2026

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

Static refresh token
A token that never changes means a single XSS incident, network interception, or malicious extension captures permanent access. The attacker's token works until it expires (often 30–90 days) or you manually revoke it.
No revocation without a denylist
JWTs are stateless — you can't "unissue" one without a server-side store. Most systems skip this complexity and simply rely on expiry, leaving a wide theft window.
Silent session hijacking
Without rotation, there's no signal when a stolen token is being used. The attacker looks identical to the legitimate user.
Logout doesn't help
If logout only discards the client-side token, a stolen copy still works. Server-side revocation is the only real logout.

The Rotation Model

Every refresh token has three properties stored server-side:

token
A cryptographically random string (UUID v4 or 32 bytes of urandom, base64-encoded). Not a JWT — random is better here.
family_id
All tokens issued to one login session share a family ID. When reuse is detected, the whole family is killed.
parent_token
The token this one replaced. Used to detect replay: if someone presents a token that has already been rotated, you can tell it's stale.

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

1
Login
Issue access token (15 min JWT) + refresh token (random, stored in Redis). Set refresh token in httpOnly cookie.
2
API request
Client sends access token in Authorization: Bearer header. Server validates JWT locally — no Redis lookup needed.
3
Access token expires
Client calls /auth/refresh with the httpOnly cookie. Server looks up the refresh token in Redis.
4
Rotation
Delete old token, create new token in same family, set rt-rotated marker. Return new access JWT + set new refresh cookie.
5
Replay detected
Old refresh token presented again → rt-rotated record exists → kill family-killed flag → client gets 401 → must log in.
6
Logout
Delete the refresh token from Redis immediately. Set cookie maxAge=0. The access token expires naturally within 15 min.

Inspect Your JWT Claims

Check the exp claim on your access tokens — short expiry is the complement to refresh token rotation.

Open JWT Decoder →

Related