Authentication at Scale
Latency, caching, and the failure modes that only appear at a hundred thousand requests a second.
Authentication runs on every request, so it is on the critical path of everything. At scale the questions stop being about correctness and start being about milliseconds and blast radius.
The cost of each approach
| Mechanism | Per-request cost | Dependency |
|---|---|---|
| JWT, HS256 | ~10µs | none |
| JWT, RS256 verify | ~50–100µs | cached JWKS |
| JWT, ES256 verify | ~100–200µs | cached JWKS |
| Session lookup, Redis | ~0.5–2ms | Redis |
| Session lookup, SQL | ~1–10ms | the database |
| Token introspection | ~5–50ms | the auth server |
Two orders of magnitude between the top and the bottom. That is the real argument for stateless verification — not elegance.
Caching, carefully
- JWKS — cache for minutes; refresh on unknown
kidwith a cooldown so a bogus kid cannot trigger unbounded fetches. - Introspection — cache for a few seconds. Even 5 seconds removes almost all the calls while capping revocation delay at 5 seconds.
- Sessions — a small in-process LRU in front of Redis absorbs the hot users, at the cost of a bounded staleness window you must be able to state.
- User/permission lookups — cache with an explicit invalidation on role change, not just a TTL.
Failure modes to design for
- Thundering herd on expiry. Ten thousand tokens minted in the same deploy expire in the same second, and every client refreshes at once. Add jitter to token lifetimes.
- Refresh stampede per client. Six parallel requests, six refreshes, reuse detection fires. One shared in-flight promise.
- Cache stampede. A hot key expires and a thousand workers recompute it. Use a lock, or serve stale while revalidating.
- Redis outage. Sessions stop resolving. Decide in advance: fail closed (correct) with a clear error, never fail open.
The measurement that matters
Track p99 of the auth middleware separately from the handler. A p50 of 200µs and a p99 of 400ms means a cache is missing far more than you think, and the average hides it entirely.
Example
// Jitter token lifetimes so a deploy does not create a synchronised expiry wave
function accessTokenTtlSeconds() {
const base = 600; // 10 minutes
const jitter = Math.floor(Math.random() * 120) - 60; // ±60s
return base + jitter;
}
// Without this, every token minted during a rollout expires within the same
// second — and your refresh endpoint sees the whole fleet at once.When to use it
- An API cuts p99 latency by 40ms after replacing per-request introspection with a five-second cache, with revocation still effectively immediate.
- A synchronised refresh wave after a deploy is eliminated by adding jitter to access token lifetimes.
- A Redis outage returns clean 503s from the auth layer rather than silently admitting unauthenticated traffic.
More examples
Layered caching with a single-flight guard
Caching negative lookups briefly is easy to forget and matters: without it, a flood of requests bearing an invalid session id hits Redis on every single one.
import { LRUCache } from 'lru-cache';
// L1: in-process, tiny TTL. Absorbs the hot users with zero network.
const l1 = new LRUCache({ max: 10_000, ttl: 5_000 });
// Single-flight: one recompute per key, however many callers arrive at once.
const inFlight = new Map();
async function cachedSession(sid) {
const hit = l1.get(sid);
if (hit !== undefined) return hit;
const existing = inFlight.get(sid);
if (existing) return existing; // ← prevents the cache stampede
const promise = (async () => {
try {
// L2: Redis, the source of truth.
const raw = await redis.get(`sess:${hash(sid)}`);
const value = raw ? JSON.parse(raw) : null;
l1.set(sid, value, { ttl: value ? 5_000 : 1_000 }); // cache misses briefly too
return value;
} finally {
inFlight.delete(sid);
}
})();
inFlight.set(sid, promise);
return promise;
}
// The staleness contract you must be able to state out loud:
// "Revocation takes effect within 5 seconds, because that is the L1 TTL."
// If that is unacceptable, publish invalidations instead of shortening the TTL
// to zero:
redis.subscribe('session-revoked', (sid) => l1.delete(sid));
// Measure the layer, not just the endpoint.
app.use((req, res, next) => {
const start = process.hrtime.bigint();
res.on('finish', () => {
metrics.histogram('auth.duration_us',
Number(process.hrtime.bigint() - start) / 1000,
{ result: req.user ? 'ok' : 'rejected', layer: req.authLayer ?? 'none' });
});
next();
});Failing closed, visibly
Choosing 503 over 401 for infrastructure failures is a scale decision as much as a correctness one: mass 401s trigger a login stampede on top of the original incident.
// ❌ The bypass that gets written during an incident and never removed
async function authBad(req, res, next) {
try {
req.user = await lookupSession(req.cookies.sid);
} catch (err) {
logger.warn('session store down, allowing request'); // ← total bypass
req.user = { id: 'unknown' };
}
next();
}
// ✅ Fail closed, and say which kind of failure it was
async function auth(req, res, next) {
let session;
try {
session = await lookupSession(req.cookies.sid);
} catch (err) {
// Infrastructure failure is NOT an authentication failure. 503 tells the
// client to retry; 401 would make it throw away a perfectly good session.
metrics.increment('auth.store_error');
logger.error({ err }, 'session store unavailable');
res.set('Retry-After', '5');
return res.status(503).json({ error: 'service_unavailable' });
}
if (!session) return res.status(401).json({ error: 'authentication_required' });
req.user = { id: session.userId };
next();
}
// The distinction matters operationally:
// 401 → "your credential is bad" → client re-authenticates
// 503 → "we are broken, not you" → client retries with backoff
// Returning 401 during an outage logs your entire user base out, and they all
// hit the login endpoint at once — turning a partial outage into a full one.Permission caching with explicit invalidation
Pub/sub invalidation gives you a long TTL and near-immediate correctness at once, which a TTL alone can never provide.
// Roles change rarely and are read constantly — a good cache. But a TTL alone
// means a demoted admin keeps their powers until it expires.
const permCache = new LRUCache({ max: 50_000, ttl: 60_000 });
export async function permissionsFor(userId, tenantId) {
const key = `${userId}:${tenantId}`;
const hit = permCache.get(key);
if (hit) return hit;
const membership = await db.memberships.findOne({ userId, tenantId });
const perms = membership ? SCOPES_BY_ROLE[membership.role] : [];
permCache.set(key, perms);
return perms;
}
// Any write to a membership publishes an invalidation to EVERY instance.
export async function changeRole(userId, tenantId, role) {
await db.memberships.update({ userId, tenantId }, { role });
// Local + fleet-wide. The publish is what makes the cache safe.
permCache.delete(`${userId}:${tenantId}`);
await redis.publish('perms-changed', JSON.stringify({ userId, tenantId }));
// Irreversible removals should not wait for a cache at all.
if (role === 'removed') {
await destroyAllSessionsFor(userId, tenantId);
await db.users.increment(userId, 'tokenVersion');
}
}
redis.subscribe('perms-changed', (msg) => {
const { userId, tenantId } = JSON.parse(msg);
permCache.delete(`${userId}:${tenantId}`);
});
// Rule of thumb for the interview: cache reads, invalidate on writes, and for
// anything irreversible go to the source of truth. A TTL is a guess about how
// much staleness is acceptable; an invalidation is an answer.
Discussion