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 +
kidfor key rotation. Validateiss,aud,exp,nbf,iat,jti, andsidwith 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
LaxorStrict;Noneonly when truly cross‑site).
Core claims (access JWT)
iss, aud, sub, exp, iat, nbf?, jti, sid, scope/permissions, ver (token_version)
sidlets you revoke one device without nuking all devices.verlets you invalidate all old tokens after a password change by bumping a user counter.
2) Expiration & validation
- Keep
expshort (5–15 min). Usenbf/iatfor clock skew handling (allow ~±60s tolerance). - Validate all of: signature,
iss,aud(match your API),exp,nbf, and optionallyjti(denylisted?) andver(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
- Client calls /refresh with current refresh token (cookie).
- Server verifies the session row: status active, matches
session_id, not used recently. - Server issues a new access JWT and a new refresh token, stores the new token’s hash + marks the old one used.
- If an old refresh token is presented again → possible theft → revoke 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
sessionstable keyed bysidwithstatus. APIs accept requests when: JWT valid and sessionstatus='active'andvermatches 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
kidper 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(orStrictif UX allows). For truly cross‑site flows, useSameSite=None; Secureand 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/audchecked,sid/jtipresent. - [ ] 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
- Shorten access token
expto 10m; addsid,jti, andverclaims. - Switch refresh to rotating opaque tokens with a sessions table and reuse detection.
- Enforce
iss/audandclockTolerancein every verifier; add Redis denylist for emergency revokes. - Publish JWKS with
kid; start a key rotation cadence. - Wire “Logout of this device / all devices” to flip session status and bump
veron critical changes.