typescript 128 lines · 3 tabs

JWT access + refresh token rotation (conceptual)

Shared by codesnips Jan 2026
3 tabs
import { randomBytes, createHash } from "crypto";
import jwt from "jsonwebtoken";
import { RefreshTokenStore } from "./store";

const ACCESS_SECRET = process.env.ACCESS_SECRET!;
const ACCESS_TTL = "15m";
const REFRESH_TTL_MS = 1000 * 60 * 60 * 24 * 30;

export function hashToken(raw: string): string {
  return createHash("sha256").update(raw).digest("hex");
}

export function signAccessToken(userId: string, sessionId: string): string {
  return jwt.sign({ sub: userId, sid: sessionId }, ACCESS_SECRET, {
    expiresIn: ACCESS_TTL,
  });
}

export async function issueRefreshToken(
  store: RefreshTokenStore,
  userId: string,
  familyId: string
): Promise<string> {
  const raw = randomBytes(48).toString("base64url");
  await store.save({
    tokenHash: hashToken(raw),
    userId,
    familyId,
    used: false,
    rotatedTo: null,
    expiresAt: Date.now() + REFRESH_TTL_MS,
  });
  return raw;
}
3 files · typescript Explain with highlit

This snippet shows the conceptual mechanics of a JWT access + refresh token scheme where refresh tokens are rotated on every use and stolen tokens are detected by tracking a reuse. Short-lived access tokens keep authorization decisions cheap and stateless, but they cannot be revoked once issued, so the refresh token becomes the long-lived credential that must be handled carefully. The pattern here treats refresh tokens as single-use: each successful refresh invalidates the old token and issues a fresh pair, and any attempt to use an already-consumed token is treated as a compromise signal.

In tokens.ts the two token types are minted with very different lifetimes. signAccessToken produces a stateless 15-minute JWT carrying only the subject and a session id, while issueRefreshToken generates a random opaque string, hashes it with sha256, and stores the hash in RefreshTokenStore keyed to a familyId. Storing only the hash means a database leak does not immediately hand an attacker usable tokens. The familyId links every token descended from a single login, which is what makes reuse detection possible.

The RefreshTokenStore in store.ts models the persistence a real system would push into Redis or Postgres. Each record tracks whether it has been used and which token it was rotatedTo. rotate is the core operation: it looks up the presented token by hash, and if the record is already used it calls revokeFamily to kill the entire lineage — this is the reuse-detection branch. A legitimate client only ever holds the newest token, so a second use of an old token means either a replay or a leaked copy, and revoking the whole family logs out the attacker and the victim together, forcing a clean re-login.

The auth routes tab wires this into Express. /refresh reads the refresh token from an httpOnly cookie, calls store.rotate, and on success sets a new cookie and returns a new access token. When rotate throws TokenReuseError, the handler clears the cookie and responds 401, so the client is forced back through login. The trade-off is statefulness: refresh tokens require server-side storage and lookups, unlike the stateless access token, but that is precisely what enables revocation. Pitfalls to watch include races from concurrent refreshes (a mobile app firing two refreshes at once can trip false reuse) and always scoping cookies with httpOnly, secure, and sameSite.


Related snips

Share this code

Here's the card — post it anywhere.

JWT access + refresh token rotation (conceptual) — share card
Link copied