caduh

JWTs — Expiration, Rotation, and Revocation

6 min read

Design access + refresh flows that are safe: short-lived access tokens, rotating refresh tokens with reuse detection, device-scoped sessions, and practical revocation strategies.

TL;DR

  • Use short‑lived access tokens (5–15 min) with narrow scopes; keep them in memory on the client.
  • Use rotating refresh tokens stored in HttpOnly+Secure cookies. Bind each refresh to a session ID (sid) and rotate on every use.
  • Detect theft with refresh reuse detection: if an old refresh token shows up after rotation, revoke that session and require re‑auth.
  • Prefer asymmetric keys (RS256/ES256) with JWKS + kid for key rotation. Validate iss, aud, exp, nbf, iat, jti, and sid with small clock skew.
  • Revocation options: per‑session store (best), denylist JTIs (TTL until exp), or opaque tokens + introspection when you need instant kill.
  • Logout = delete refresh cookie + mark session revoked; access tokens die naturally in a few minutes.

1) Token types & where they live

  • Access token (JWT): signed, presented to APIs; expires quickly (e.g., 10m). Store in memory (SPA) or short‑lived cookie.
  • Refresh token: long‑lived credential to get new access tokens.
    • Use an opaque random string (recommended) backed by a DB/Redis session row, or a JWT plus a server session row.
    • Store in HttpOnly, Secure cookie (SameSite Lax or Strict; None only when truly cross‑site).

Core claims (access JWT)

iss, aud, sub, exp, iat, nbf?, jti, sid, scope/permissions, ver (token_version)

sid lets you revoke one device without nuking all devices. ver lets you invalidate all old tokens after a password change by bumping a user counter.


2) Expiration & validation

  • Keep exp short (5–15 min). Use nbf/iat for clock skew handling (allow ~±60s tolerance).
  • Validate all of: signature, iss, aud (match your API), exp, nbf, and optionally jti (denylisted?) and ver (matches user row).

Node (verify with JWKS)

import { createRemoteJWKSet, jwtVerify } from "jose";

const JWKS = createRemoteJWKSet(new URL("https://auth.example.com/.well-known/jwks.json"));

export async function verifyAccess(token: string) {
  const { payload, protectedHeader } = await jwtVerify(token, JWKS, {
    issuer: "https://auth.example.com",
    audience: "api://orders",
    clockTolerance: "60s",
  });
  // check app-specific claims
  if (!payload.sid || !payload.jti) throw new Error("invalid_claims");
  return payload; // { sub, sid, jti, scope, ver, ... }
}

3) Refresh rotation (sliding sessions) with reuse detection

Flow

  1. Client calls /refresh with current refresh token (cookie).
  2. Server verifies the session row: status active, matches session_id, not used recently.
  3. Server issues a new access JWT and a new refresh token, stores the new token’s hash + marks the old one used.
  4. If an old refresh token is presented again → possible theftrevoke the session (and optionally notify the user).

Schema (Postgres sketch)

CREATE TABLE sessions (
  sid uuid PRIMARY KEY,
  user_id bigint NOT NULL,
  current_refresh_hash bytea NOT NULL,
  rotated_at timestamptz NOT NULL,
  status text NOT NULL CHECK (status IN ('active','revoked')),
  created_at timestamptz NOT NULL DEFAULT now(),
  last_ip inet, last_ua text
);

Handler (Node/TS + Redis hash, concept)

import crypto from "node:crypto";
const ttlDays = 30;

function hash(token: string) { return crypto.createHash("sha256").update(token).digest("base64url"); }

// Exchange refresh -> new tokens
export async function refresh(req, res) {
  const token = req.cookies["refresh_token"];
  if (!token) return res.status(401).end();
  const { sid } = parseSignedToken(token); // if you sign/wrap the opaque string

  const s = await db.sessions.get(sid);
  if (!s || s.status !== "active") return res.status(401).end();

  const presented = hash(token);
  if (presented !== s.current_refresh_hash) {
    // REUSE DETECTED: someone used an older token
    await db.sessions.update(sid, { status: "revoked" });
    return res.status(401).json({ error: "session_revoked" });
  }

  // rotate
  const newRefresh = crypto.randomBytes(32).toString("base64url");
  await db.sessions.update(sid, { current_refresh_hash: hash(newRefresh), rotated_at: new Date() });

  const access = await signAccessJWT({ sub: s.user_id, sid, jti: randomId(), ver: await getUserVer(s.user_id) }, "10m");
  res
    .cookie("refresh_token", wrapAndSign(newRefresh), { httpOnly: true, secure: true, sameSite: "Lax", maxAge: ttlDays*864e5, path: "/refresh" })
    .json({ access });
}

4) Revocation strategies (choose deliberately)

A) Per‑session store (recommended)

  • Keep a sessions table keyed by sid with status. APIs accept requests when: JWT valid and session status='active' and ver matches user row.
  • Pros: instant logout per device, track last IP/UA, show “where you’re signed in.”
  • Cons: API must lookup (cache) session status on each call or accept a tiny lag.

B) Denylist JTIs (TTL)

  • On “revoke token”, add { jti → exp } to Redis; APIs reject if found. Works for access JWTs too, but best effort (requires lookup).

C) Opaque tokens + introspection

  • API receives opaque access token and calls /introspect to auth server (or consults a signed cache). Strongest revocation but adds a network hop.

D) Nuclear option

  • Key rotation: rotate signing keys to invalidate all access tokens quickly (JWKS rotation). Keep old key around briefly for grace.

5) Key management & rotation (don’t skip this)

  • Use asymmetric keys and publish JWKS with kid per key.
  • Rotate regularly (e.g., 30–90 days): add new key, start signing with it, keep old key in JWKS until all outstanding tokens expire, then retire.
  • Cache JWKS with HTTP caching (max‑age) and support kid changes without restarts.

Issue with jose

import { SignJWT } from "jose";
export async function signAccessJWT(claims, ttl = "10m") {
  return await new SignJWT(claims)
    .setProtectedHeader({ alg: "RS256", kid })
    .setIssuer("https://auth.example.com")
    .setAudience("api://orders")
    .setIssuedAt()
    .setExpirationTime(ttl)
    .sign(privateKey);
}

6) Storage & CSRF (web, SPA, mobile)

  • Web SPA: keep access token in memory; refresh in HttpOnly+Secure cookie, Path=/refresh, SameSite=Lax (or Strict if UX allows). For truly cross‑site flows, use SameSite=None; Secure and add CSRF token.
  • Traditional server‑rendered: consider session cookies for primary auth and use JWT only between services.
  • Mobile/Desktop: store refresh in OS secure storage (Keychain/Keystore); never in plaintext files.
  • Never put tokens in localStorage (XSS) or URLs (logs/leaks).

7) Microservices & gateways

  • Validate access JWTs at the edge (API gateway) and pass user context (sub, sid, scope, ver) via headers to services.
  • Services still verify JWTs locally (cached JWKS) and enforce aud.
  • For service‑to‑service auth, use mTLS or client credentials with separate audiences/scopes.

8) Pitfalls & fast fixes

| Pitfall | Why it’s risky | Fix | |---|---|---| | Long‑lived access tokens | Stolen tokens are useful for hours/days | Keep access tokens short (≤15m) | | HS256 with many verifiers | Shared secret sprawl | Use RS256/ES256 + JWKS | | No aud/iss checks | Token replay across apps | Enforce audience and issuer | | No refresh rotation | Theft is silent | Rotate on every use; detect reuse | | No per‑session revocation | Can’t log out one device | Add sessions table with sid & status | | Tokens in localStorage | XSS → full account | Store access in memory; refresh in HttpOnly cookie | | Missing ver bump on password change | Old tokens still work | Increment user token_version and check in JWT | | Unbounded clock skew | Intermittent auth failures | Allow ±60s tolerance |


Quick checklist

  • [ ] Access JWT: 10m expiry, RS256/ES256, iss/aud checked, sid/jti present.
  • [ ] Refresh: opaque, rotating, in HttpOnly+Secure cookie, reuse detection.
  • [ ] Per‑session store with status; endpoint to revoke one/all sessions.
  • [ ] Key rotation via JWKS kid; cache and monitor.
  • [ ] Web: no tokens in localStorage; CSRF token if cross‑site.
  • [ ] On password/email change: bump token_version and revoke sessions.

One‑minute adoption plan

  1. Shorten access token exp to 10m; add sid, jti, and ver claims.
  2. Switch refresh to rotating opaque tokens with a sessions table and reuse detection.
  3. Enforce iss/aud and clockTolerance in every verifier; add Redis denylist for emergency revokes.
  4. Publish JWKS with kid; start a key rotation cadence.
  5. Wire “Logout of this device / all devices” to flip session status and bump ver on critical changes.