Build JWT Auth System at Production Scale
system-design jwt security architecture backend
Fair warning: this isn’t a quick scroll. If you’re a serious engineer, you’ll love it - otherwise, you might want to skip
Anyone can implement JWTs. Very few build systems that don’t fall apart because of them. This is the only blog you will ever need to understand everything about JWT
Quick navigation
- Why would anybody use JWT - and why is it everywhere?
- System overview
- Login flow
- Request verification flow
- Token refresh and rotation
- How refresh token reuse detection actually works
- Key management and rotation
- Database schema
- Frontend - silent refresh pattern
- Security hardening checklist
- Can you crack this interview questions
1. Why would anybody use JWT - and why is it everywhere?
The problem JWT replaces
Classic server-side sessions work like this: user logs in → server stores a session in memory or a DB → sends back a session ID cookie → every request does a lookup. Fine with one server. But once you have multiple servers behind a load balancer, Server A created your session and Server B has no idea who you are. You need sticky sessions or a shared Redis store - both add complexity and a central point of failure. JWT flips the model entirely.
What JWT does differently
Instead of the server remembering you, it hands you a signed token that proves who you are. Every request carries the identity payload with it. The server just:
- Receives the token
- Verifies the cryptographic signature
- Reads the claims - no DB call, no lookup
This is what stateless auth means.
Why This Matters at Scale
- Horizontal scaling becomes trivial. Any server can verify any token independently - just needs the secret key. Spin up 50 instances and they all work immediately.
- Microservices become cleaner. With opaque session tokens, every service has to call a central auth service on every request - that’s extra latency and a single point of failure multiplied across your architecture. With JWT, each service verifies locally in microseconds.
- Cross-domain auth works naturally. Session cookies are tied to a domain. JWTs are just strings - they travel in an Authorization header and work across domains, in mobile apps, in CLI tools, anywhere HTTP works.
The honest tradeoffs
JWT isn’t universally better - it’s a different set of tradeoffs.
- You can’t invalidate a JWT early. Delete a session and the user is instantly logged out. With JWT, if a token is stolen or you need to force-logout, you can’t - it’s valid until exp passes. This is why short expiry times (15 min) + refresh tokens exist.
- The payload is encoded, not encrypted. Anyone who intercepts a JWT can read its claims - it’s just base64. Never put passwords or sensitive PII in the payload.
- Token size adds up. A session ID is ~32 bytes. A JWT with 10 claims might be 500 bytes. At high traffic volumes, this matters.
How Big Companies Actually Solved This
They didn’t pick sessions OR JWT. They built a hybrid model that takes the best of both.
The Pattern: Opaque Token + Token Introspection Service
Browser → holds opaque session token (just a random ID, like a cookie) → sends it with every UI request
API Gateway → receives the opaque token → calls a dedicated Auth Service once → Auth Service returns a rich identity object → Gateway mints a short-lived internal JWT → passes that JWT downstream to microservices
Microservices → verify the JWT locally, no network call needed
The browser never sees a JWT. Internally, JWTs fly between services. The session store is only hit once per request at the edge, not by every downstream service.
Why This Works at Scale The Auth Service lookup happens exactly once - at the API Gateway. After that, the internal JWT carries the identity context to all 10, 50, 100 downstream services without any of them touching the session store.
1 request = 1 session store lookup (at the edge) + N JWT verifications (local, microseconds each)
Where JWT Actually Belongs
| Use case | Good fit? |
|---|---|
| Service-to-service auth | ✅ Yes |
| Short-lived third-party API tokens | ✅ Yes |
| Mobile SDKs (cookies don’t work naturally) | ✅ Yes |
| User-facing login sessions in a web app | ⚠️ Usually overkill |
The irony: JWT got popular because engineers read about how Google and Meta scaled auth for microservices - then reached for JWT for their login cookie, solving a problem they didn’t have while creating ones they didn’t expect. The tool is right. The layer matters.
Alright, that’s enough theory. I’ll assume you understand where this applies, so let’s go ahead and build it.
2. System overview
Before touching any code, let’s establish the components. A production JWT auth system has five distinct layers.
Key architectural decisions visible here:
The API gateway is the only layer that does JWT signature verification - resource services trust the gateway and just read the decoded claims. This avoids every microservice needing to implement crypto. The auth service is the only component that ever touches the RSA private key, which lives in a secrets manager - never in an env file.
3. Login flow
The login flow is where everything starts. A successful login produces two tokens with very different lifetimes and storage locations.
Notice step 9 - the response body carries the access token (stored in JS memory), while Set-Cookie carries the refresh token as an httpOnly cookie. JavaScript on the page never sees the refresh token value. This is the split storage pattern that defeats XSS token theft.
Auth service - login implementation
// auth/routes/login.js
const bcrypt = require('bcrypt');
const crypto = require('crypto');
const jwt = require('jsonwebtoken');
const fs = require('fs');
const PRIVATE_KEY = fs.readFileSync(process.env.JWT_PRIVATE_KEY_PATH);
async function login(req, res) {
const { email, password } = req.body;
// 1. Find user
const user = await db.users.findOne({ email, active: true });
if (!user) return res.status(401).json({ error: 'Invalid credentials' });
// 2. Verify password - constant-time comparison via bcrypt
const valid = await bcrypt.compare(password, user.passwordHash);
if (!valid) return res.status(401).json({ error: 'Invalid credentials' });
// 3. Issue access token - short lived, stateless
const accessToken = jwt.sign(
{
sub: user.id,
role: user.role,
iss: 'auth.yourapp.com',
aud: 'api.yourapp.com',
tokenVersion: user.tokenVersion, // for emergency invalidation
},
PRIVATE_KEY,
{ algorithm: 'RS256', expiresIn: '15m', keyid: process.env.JWT_KEY_ID }
);
// 4. Issue refresh token - long lived, opaque, stored in DB
const refreshToken = crypto.randomBytes(64).toString('hex');
const familyId = crypto.randomUUID();
await db.refreshTokens.insert({
token: refreshToken,
family: familyId,
userId: user.id,
used: false,
createdAt: new Date(),
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
});
// 5. Set refresh token as httpOnly cookie - JS blind
res.cookie('refreshToken', refreshToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
path: '/auth/refresh', // only sent to this endpoint
maxAge: 30 * 24 * 60 * 60 * 1000,
});
// 6. Access token in body - UI stores in memory
return res.json({ accessToken, expiresIn: 900 });
}
4. Request verification flow
Every API request goes through the gateway’s verification middleware before reaching a resource service. This is where the JWKS cache, kid lookup, and claim validation all happen.
Gateway - verification middleware
// gateway/middleware/verifyJwt.js
const jwksClient = require('jwks-rsa');
const jwt = require('jsonwebtoken');
const redis = require('../lib/redis');
const jwks = jwksClient({
jwksUri: 'https://auth.yourapp.com/.well-known/jwks.json',
cache: true,
cacheMaxAge: 600_000, // 10 min
rateLimit: true,
jwksRequestsPerMinute: 10,
});
async function verifyJwt(req, res, next) {
const authHeader = req.headers['authorization'];
if (!authHeader?.startsWith('Bearer '))
return res.status(401).json({ error: 'Missing token' });
const token = authHeader.slice(7);
// 1. Decode header without verifying - to extract kid
let header;
try {
header = JSON.parse(
Buffer.from(token.split('.')[0], 'base64url').toString()
);
} catch {
return res.status(401).json({ error: 'Malformed token' });
}
// 2. Hard-reject explicitly blocked key IDs (compromised keys)
const isBlocked = await redis.sIsMember('blocked_kids', header.kid);
if (isBlocked)
return res.status(401).json({ error: 'Key revoked - please re-authenticate' });
// 3. Fetch public key and verify signature
let decoded;
try {
decoded = await new Promise((resolve, reject) => {
jwt.verify(
token,
(hdr, cb) => jwks.getSigningKey(hdr.kid, (err, key) =>
err ? cb(err) : cb(null, key.getPublicKey())
),
{
algorithms: ['RS256'], // never allow 'none'
issuer: 'auth.yourapp.com',
audience: 'api.yourapp.com',
},
(err, payload) => err ? reject(err) : resolve(payload)
);
});
} catch (err) {
return res.status(401).json({ error: 'Invalid token', detail: err.message });
}
// 4. Check token version (handles emergency invalidation)
const user = await db.users.findById(decoded.sub);
if (!user || decoded.tokenVersion !== user.tokenVersion)
return res.status(401).json({ error: 'Session invalidated' });
// 5. Replay guard - check jti nonce hasn't been seen before
if (decoded.jti) {
const seen = await redis.set(`jti:${decoded.jti}`, '1', {
NX: true, // only set if not exists
EX: 900, // expire with the token (15 min)
});
if (!seen)
return res.status(401).json({ error: 'Token replay detected' });
}
// 6. Inject user context - downstream services read these headers
req.headers['x-user-id'] = decoded.sub;
req.headers['x-user-role'] = decoded.role;
delete req.headers['authorization']; // don't leak downstream
next();
}
5. Token refresh and rotation
The refresh flow is the most security-sensitive endpoint in the system. It’s where theft detection happens and where a compromised session gets terminated.
Auth service - refresh endpoint
// auth/routes/refresh.js
const crypto = require('crypto');
const jwt = require('jsonwebtoken');
async function refresh(req, res) {
const oldRefreshToken = req.cookies.refreshToken;
if (!oldRefreshToken)
return res.status(401).json({ error: 'No refresh token' });
// 1. Find token in DB
const stored = await db.refreshTokens.findOne({ token: oldRefreshToken });
if (!stored)
return res.status(401).json({ error: 'Invalid token' });
// 2. Check expiry
if (stored.expiresAt < new Date())
return res.status(401).json({ error: 'Refresh token expired' });
// 3. THEFT DETECTION - token reuse
if (stored.used) {
// Attacker or real user reused a token - treat as compromise
await db.refreshTokens.updateMany(
{ family: stored.family },
{ revoked: true, revokedReason: 'reuse_detected', revokedAt: new Date() }
);
// Optional: alert security team
await alertSecurityTeam({ userId: stored.userId, event: 'token_reuse' });
return res.status(401).json({ error: 'Token reuse detected - please log in again' });
}
// 4. Mark old token as used - don't delete, keep for forensics
await db.refreshTokens.update(
{ token: oldRefreshToken },
{ used: true, usedAt: new Date() }
);
// 5. Get user and bump check tokenVersion
const user = await db.users.findById(stored.userId);
if (!user || !user.active)
return res.status(401).json({ error: 'Account inactive' });
// 6. Issue new access token
const newAccessToken = jwt.sign(
{
sub: user.id,
role: user.role,
iss: 'auth.yourapp.com',
aud: 'api.yourapp.com',
tokenVersion: user.tokenVersion,
jti: crypto.randomUUID(), // replay guard
},
PRIVATE_KEY,
{ algorithm: 'RS256', expiresIn: '15m', keyid: process.env.JWT_KEY_ID }
);
// 7. Issue new refresh token - same family
const newRefreshToken = crypto.randomBytes(64).toString('hex');
await db.refreshTokens.insert({
token: newRefreshToken,
family: stored.family, // same family
userId: stored.userId,
used: false,
createdAt: new Date(),
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
});
// 8. Set new cookie + return new access token
res.cookie('refreshToken', newRefreshToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
path: '/auth/refresh',
maxAge: 30 * 24 * 60 * 60 * 1000,
});
return res.json({ accessToken: newAccessToken, expiresIn: 900 });
}
6. How refresh token reuse detection actually works
This is the most misunderstood part of the system. A common question is: “if the UI calls /auth/refresh regularly, doesn’t that count as reuse?” No - and understanding why reveals exactly how theft detection works.
The UI never reuses a token
Every call to /auth/refresh returns a brand new refresh token via Set-Cookie. The browser replaces the old cookie automatically. So the next refresh call always sends the new token - the old one is already dead.
Login
└── Server issues: token_A (used: false)
Browser cookie: token_A
UI calls /auth/refresh (15 min later)
└── Sends: token_A
Server marks: token_A → used: true
Server issues: token_B (used: false)
Browser cookie: token_B ← replaces token_A automatically
UI calls /auth/refresh again
└── Sends: token_B ← NOT token_A, always the latest
Server marks: token_B → used: true
Server issues: token_C
Browser cookie: token_C
The browser holds exactly one refresh token at any moment. Reuse can only come from an outside party holding a stale token.
The two theft scenarios
Why “don’t delete used tokens” is critical
This is the implementation detail that makes detection possible. If you delete the token after use, a reused token looks identical to an unknown token - you can’t tell the difference.
// ❌ Wrong - delete on use
await db.refreshTokens.delete({ token: oldToken });
// When attacker sends token_A:
// → "not found" → generic 401
// → no idea if it was stolen or just expired
// → no family revocation
// ✅ Correct - mark as used, keep the row
await db.refreshTokens.update({ token: oldToken }, { used: true });
// When attacker sends token_A:
// → found, used === true
// → REUSE - revoke entire family
// → both parties logged out, real user notified
The used flag is what turns a regular 401 into a security event. The row must survive after use - it’s the tripwire.
The race condition edge case
There is one edge case worth knowing: what if the UI sends two refresh requests simultaneously? (Double tab open, race condition on app boot.) Both send token_A - the first succeeds, the second hits used: true and triggers a false alarm.
The fix is an atomic DB operation using SELECT FOR UPDATE or an optimistic lock:
// Atomic check-and-mark using a single UPDATE
// Only succeeds if used is still false
const result = await db.refreshTokens.updateOne(
{ token: oldToken, used: false }, // condition
{ $set: { used: true, usedAt: new Date() } },
{ returnDocument: 'before' } // return pre-update state
);
if (!result) {
// Either token doesn't exist OR was already used
// Check which - if it existed and was used, it's reuse
const existing = await db.refreshTokens.findOne({ token: oldToken });
if (existing?.used) {
await revokeFamily(existing.family);
return res.status(401).json({ error: 'Token reuse detected' });
}
return res.status(401).json({ error: 'Invalid token' });
}
// result is the pre-update doc - proceed with rotation
This makes the check-and-mark atomic so two simultaneous legitimate requests can’t both succeed and accidentally trigger a false family revocation.
7. Key management and rotation
Key management is what separates a production auth system from a demo. The private key is the single point of trust for your entire authentication layer - if it leaks, every token ever issued by it is compromised.
Where the key lives
Never in an env file. Never on disk. Never in your repo. The private key must live in a dedicated secrets store and be fetched at runtime.
❌ JWT_PRIVATE_KEY=-----BEGIN RSA PRIVATE KEY-----... (env file)
❌ /app/keys/private.pem (disk)
❌ git commit -m "add key" (repo)
✅ AWS Secrets Manager
✅ HashiCorp Vault
✅ GCP Secret Manager
✅ Hardware Security Module (HSM) - strongest option
The auth service fetches the key once at startup and holds it in memory. Nothing else in the system ever sees it.
// auth/lib/keys.js - fetch on boot, never log or expose
const { SecretsManagerClient, GetSecretValueCommand } = require('@aws-sdk/client-secrets-manager');
let _privateKey = null;
let _currentKid = null;
async function loadSigningKey() {
const client = new SecretsManagerClient({ region: 'ap-south-1' });
const result = await client.send(
new GetSecretValueCommand({ SecretId: 'jwt/private-key' })
);
const secret = JSON.parse(result.SecretString);
_privateKey = secret.privateKey; // PEM string
_currentKid = secret.kid; // e.g. "key-2026-04"
console.log(`Signing key loaded: ${_currentKid}`);
// NEVER log the key itself - only the kid
}
module.exports = {
loadSigningKey,
getPrivateKey: () => _privateKey,
getCurrentKid: () => _currentKid,
};
What kid is and why it matters
Every JWT has a kid (key ID) stamped in its header when signed:
{
"alg": "RS256",
"kid": "key-2026-04",
"typ": "JWT"
}
When a service verifies the token, it reads the kid, fetches the matching public key from the JWKS endpoint, and verifies the signature with that specific key. This is what makes rotation seamless - multiple keys can be active simultaneously and each token knows exactly which one signed it.
The JWKS endpoint
The auth service publishes all currently active public keys at a standard URL. Gateways and services fetch and cache from here - they never need the private key.
// auth/routes/jwks.js
const publicKeys = new Map([
// kid → PEM public key
// Add new entries here when rotating, remove old ones after expiry window
['key-2026-04', process.env.PUBLIC_KEY_2026_04],
['key-2026-05', process.env.PUBLIC_KEY_2026_05], // new key during rotation
]);
app.get('/.well-known/jwks.json', (req, res) => {
res.set('Cache-Control', 'public, max-age=600'); // gateways cache 10 min
const keys = Array.from(publicKeys.entries()).map(([kid, pem]) => {
const pubComponents = extractRsaComponents(pem); // n and e
return {
kid,
kty: 'RSA',
use: 'sig',
alg: 'RS256',
n: pubComponents.n,
e: pubComponents.e,
};
});
res.json({ keys });
});
Planned rotation - zero downtime
Routine key rotation should happen every 90 days. The overlap window is what makes it zero downtime - old tokens keep working until they expire naturally.
Day 0 (today)
└── Active key: key-2026-04
JWKS: [ key-2026-04 ]
All tokens signed with key-2026-04
Day 90 (rotation day)
Step 1: Generate new key pair → kid: key-2026-07
Step 2: Upload new private key to secrets manager
Step 3: Add key-2026-07 public key to JWKS endpoint
JWKS: [ key-2026-04, key-2026-07 ] ← both active
Step 4: Rolling restart auth service → now signs with key-2026-07
New tokens → key-2026-07
Old tokens → key-2026-04 (still verified, still in JWKS)
Day 90 + 15min (access tokens expired)
Step 5: Remove key-2026-04 from JWKS
JWKS: [ key-2026-07 ]
Old key-2026-04 tokens have all expired naturally ✓
# Step 1 - Generate new key pair
openssl genrsa -out new-private.pem 4096
openssl rsa -in new-private.pem -pubout -out new-public.pem
# Step 2 - Upload to secrets manager
aws secretsmanager put-secret-value \
--secret-id jwt/private-key \
--secret-string "{\"privateKey\":\"$(cat new-private.pem)\",\"kid\":\"key-2026-07\"}"
# Step 3 - Rolling restart (Kubernetes)
kubectl rollout restart deployment/auth-service
Emergency rotation - key compromised
This is the nuclear scenario. You don’t have time for a graceful overlap window - every token signed with the compromised key must be treated as untrusted immediately.
Minute 0: Compromise detected
Minute 1: Generate new key pair
Minute 2: Upload new private key to secrets manager
Minute 3: Rolling restart auth service (signs with new key)
Minute 4: Blocklist old kid in Redis
Minute 5: Force re-auth for all users
Minute 6: Remove old key from JWKS
// Step 4 - Blocklist the compromised kid
// Gateway checks this on every request before signature verification
await redis.sAdd('blocked_kids', 'key-2026-04');
// Any token with kid=key-2026-04 is now rejected at the gateway
// even if the signature is cryptographically valid
// Step 5 - Force re-auth for all users
// Two-pronged: revoke refresh tokens AND bump tokenVersion
async function emergencyInvalidateAll() {
// Revoke all refresh tokens - no one can silently restore a session
await db.refreshTokens.updateMany(
{ revoked: false },
{
revoked: true,
revokedReason: 'key_compromise',
revokedAt: new Date(),
}
);
// Bump tokenVersion on every user
// Any in-flight access token with an older version fails claim check
await db.users.updateMany(
{},
{ $inc: { tokenVersion: 1 } }
);
}
The blocklist handles tokens already in flight. The tokenVersion bump handles any tokens the blocklist misses - for example, tokens signed with the new key but issued before you detected the breach.
The overlap window visualised
Timeline →
key-2026-04 ████████████████████░░░░ (signs until restart, verified until removed)
key-2026-07 ░░░░████████████████████ (signs after restart)
↑ ↑
rotation day JWKS cleanup
both keys in (15 min later
JWKS here for access tokens,
or never for
emergency rotation)
During the overlap window, the JWKS endpoint serves both public keys. Tokens signed with either key verify successfully - the kid tells the verifier which one to use. Once the overlap window closes and the old key is removed from JWKS, any remaining old-kid tokens fail verification, but by then they’ve all expired naturally.
Key rotation checklist
| Step | Planned | Emergency |
|---|---|---|
| Generate new key pair | ✓ | ✓ |
| Upload to secrets manager | ✓ | ✓ |
| Add new public key to JWKS | ✓ | ✓ |
| Restart auth service | Rolling (no downtime) | Immediate |
| Blocklist old kid in Redis | Not needed | ✓ |
| Revoke all refresh tokens | Not needed | ✓ |
| Bump tokenVersion on users | Not needed | ✓ |
| Remove old key from JWKS | After token expiry (15 min) | Immediately |
The key difference: planned rotation relies on natural token expiry to drain old tokens. Emergency rotation can’t wait - it blocklists the old kid and forces everyone to re-authenticate immediately.
8. Database schema
The refresh token table is the stateful backbone of the system. Every security decision - rotation, theft detection, family revocation - depends on querying it efficiently.
-- Users table
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user',
token_version INT NOT NULL DEFAULT 0, -- bump to invalidate all JWTs
active BOOL NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Refresh tokens table
CREATE TABLE refresh_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
token TEXT UNIQUE NOT NULL, -- opaque random hex
family UUID NOT NULL, -- groups tokens from same login
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
used BOOL NOT NULL DEFAULT false, -- tripwire for theft detection
revoked BOOL NOT NULL DEFAULT false,
revoked_reason TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
used_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ NOT NULL
);
-- Indexes - every query path needs one
CREATE INDEX idx_rt_token ON refresh_tokens(token);
CREATE INDEX idx_rt_family ON refresh_tokens(family);
CREATE INDEX idx_rt_user ON refresh_tokens(user_id);
CREATE INDEX idx_rt_expires ON refresh_tokens(expires_at);
-- Cleanup job - run nightly
DELETE FROM refresh_tokens
WHERE expires_at < now() - INTERVAL '7 days'
AND used = true;
9. Frontend - silent refresh pattern
The UI never stores tokens in localStorage. The access token lives in a JS module variable and is silently refreshed before it expires.
// auth/client.js
let _accessToken = null;
let _refreshTimer = null;
export async function login(email, password) {
const res = await fetch('/auth/login', {
method: 'POST',
credentials: 'include', // sends/receives cookies
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
if (!res.ok) throw new Error('Login failed');
const { accessToken, expiresIn } = await res.json();
_accessToken = accessToken;
scheduleRefresh(expiresIn);
return accessToken;
}
export async function refreshTokens() {
const res = await fetch('/auth/refresh', {
method: 'POST',
credentials: 'include', // httpOnly cookie sent automatically
});
if (!res.ok) {
_accessToken = null;
window.location.href = '/login';
return;
}
const { accessToken, expiresIn } = await res.json();
_accessToken = accessToken;
scheduleRefresh(expiresIn);
}
function scheduleRefresh(expiresInSeconds) {
clearTimeout(_refreshTimer);
// Refresh 60 seconds before expiry
_refreshTimer = setTimeout(refreshTokens, (expiresInSeconds - 60) * 1000);
}
export async function apiFetch(url, options = {}) {
if (!_accessToken) await refreshTokens(); // try to restore session
return fetch(url, {
...options,
headers: {
...options.headers,
'Authorization': `Bearer ${_accessToken}`,
},
});
}
export async function logout() {
clearTimeout(_refreshTimer);
_accessToken = null;
await fetch('/auth/logout', { method: 'POST', credentials: 'include' });
}
What happens on tab close
Tab closes
- JS memory wiped → access token gone
- httpOnly cookie → still alive (it’s a persistent cookie)
User reopens tab
- accessToken = null → can’t make API calls
- refreshToken cookie → still there, browser sends it automatically
So on reopen, accessToken is null but the cookie survives - just silently call /auth/refresh on page load before the user sees anything.
Silent restore on page load
// App entry point - React example
import { initAuth } from './auth/client';
async function bootstrap() {
const restored = await initAuth();
if (restored) {
renderApp(); // user sees their dashboard
} else {
renderLoginPage(); // cookie expired or never existed
}
}
bootstrap();
The user experience is seamless - they close the tab, come back the next day, the app calls /auth/refresh silently on boot, gets a new access token from the cookie, and lands straight on their dashboard. They never see a login page unless the refresh token itself has expired (30 days).
10. Security hardening checklist
| Control | Implementation | Why |
|---|---|---|
| RS256 only | algorithms: ['RS256'] in verify | Blocks alg:none attack |
| httpOnly cookie | res.cookie(..., { httpOnly: true }) | Defeats XSS token theft |
| sameSite: strict | on refresh token cookie | Blocks CSRF on refresh endpoint |
| Short access token TTL | expiresIn: '15m' | Limits stolen token window |
| Refresh token rotation | mark used + issue new | Detects stolen refresh tokens |
| Family revocation | revoke all on reuse | Terminates both parties on theft |
| kid blocklist | Redis set, checked pre-verify | Emergency key compromise response |
| tokenVersion claim | incremented on force-logout | Invalidates all issued JWTs instantly |
| jti nonce | UUID per token, checked in Redis | Prevents token replay attacks |
| Private key in HSM | AWS Secrets Manager / Vault | Key exfiltration resistance |
| JWKS cache | 10 min TTL at gateway | Performance + resilience |
| path scoping | cookie path: ‘/auth/refresh’ | Cookie only sent to one endpoint |
The single most important decision in this entire design is where you store the refresh token. httpOnly cookie with sameSite:strict is not optional - it is the foundation that makes every other control meaningful. Without it, XSS bypasses the entire token rotation system.