Token introspection (RFC 7662) lets a resource server ask the authorization server: "is this token currently valid?" The authorization server responds with the token's metadata if valid, or {"active": false} if not. It is the cleanest model for revocation — revoke a token, and the next introspection call reflects that immediately. The problem is latency: every API request requires a round trip to the authorization server. At scale, this becomes the bottleneck. The answer is not to abandon introspection but to cache it intelligently.
Baseline introspection latency
A typical introspection call within the same data center takes 2–5ms. Cross-region, that becomes 50–150ms. If your API handler takes 20ms and introspection adds 100ms, you have quintupled your API response time. At 1000 requests per second, your auth server is fielding 1000 introspection calls per second — for a single downstream service.
// Naively calling introspection on every request — do not do this at scale
async function authenticate(req) {
const token = extractBearerToken(req);
const introspectResp = await fetch('https://auth.yourapp.com/oauth/introspect', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Authorization': `Basic ${btoa(`${RS_CLIENT_ID}:${RS_CLIENT_SECRET}`)}`,
},
body: new URLSearchParams({ token }),
});
const data = await introspectResp.json();
if (!data.active) throw new Error('Token inactive');
return data;
// Round trip on every request — latency multiplier
}
Caching introspection responses
The straightforward optimization: cache the introspection response keyed by the token value (or its SHA-256 hash) with a TTL equal to the time remaining before the token expires. A revoked token will continue to appear valid until the cache entry expires — this is the accepted trade-off.
import { createHash } from 'crypto';
// Cache introspection responses in Redis
// TTL: min(cache_max_ttl, token_remaining_lifetime)
const CACHE_MAX_TTL = 60; // maximum 60 seconds staleness for revocation
async function introspectWithCache(token) {
const tokenHash = createHash('sha256').update(token).digest('hex');
const cacheKey = `introspect:${tokenHash}`;
// Check cache first
const cached = await redis.get(cacheKey);
if (cached) {
return JSON.parse(cached);
}
// Cache miss — call auth server
const data = await callIntrospectionEndpoint(token);
if (data.active && data.exp) {
const remainingTtl = data.exp - Math.floor(Date.now() / 1000);
const cacheTtl = Math.min(CACHE_MAX_TTL, Math.max(1, remainingTtl));
await redis.setex(cacheKey, cacheTtl, JSON.stringify(data));
}
// Don't cache inactive tokens — attacker could abuse a known-invalid token
// to poison the cache and prevent re-activation if the token state changes
return data;
}
active: false responses. If a previously revoked token is re-issued with the same identifier (unlikely but possible in some systems), a cached negative would block the legitimate token. Cache only positive introspection results.JWT self-contained validation: fast but stale
Self-contained JWTs encode all necessary claims in the token itself. Verification is local — check the signature, check exp, check iss. No round trip to the auth server. Verification takes 0.1–0.5ms. The cost: you cannot revoke a token until it expires. If a user's account is compromised and you revoke their session, their JWT is still valid until exp.
For access tokens with short lifetimes (5–15 minutes), this is acceptable for most applications. The window between revocation and the token becoming invalid is bounded by the token lifetime. For longer-lived tokens or applications requiring immediate revocation (financial services, access to sensitive records), this is not acceptable.
Hybrid approach: short-lived JWTs with refresh token revocation
The standard production pattern combines both: issue short-lived JWTs (5–15 minute expiry) verified locally, and maintain revocability through the refresh token layer. When a session is revoked, the refresh token is invalidated in the database. The access JWT remains valid for up to 15 minutes, but the user cannot get a new one when it expires.
// Hybrid: local JWT validation + revocation via refresh token
async function authenticateHybrid(req) {
const accessToken = extractBearerToken(req);
// Fast path: local JWT verification
let payload;
try {
payload = await jwtVerify(accessToken, JWKS, {
issuer: 'https://auth.yourapp.com',
algorithms: ['ES256'],
});
} catch (err) {
throw new Error('Invalid token');
}
// Optional: check a local revocation bloom filter for immediate revocation
// This adds ~0.05ms and catches explicitly revoked tokens
if (await isTokenRevoked(payload.jti)) {
throw new Error('Token has been revoked');
}
return payload;
}
// Revocation via jti blocklist (for high-value revocations only)
// Store in Redis sorted set: key = jti, score = expiry timestamp
async function revokeToken(jti, expiresAt) {
await redis.zadd('revoked_jtis', expiresAt, jti);
}
async function isTokenRevoked(jti) {
if (!jti) return false;
const score = await redis.zscore('revoked_jtis', jti);
return score !== null;
}
// Periodic cleanup: remove expired entries from the blocklist
async function pruneRevokedTokens() {
const now = Math.floor(Date.now() / 1000);
await redis.zremrangebyscore('revoked_jtis', '-inf', now);
}
Edge validation
For applications behind a CDN or edge network (Cloudflare Workers, Fastly Compute, Lambda@Edge), validating JWTs at the edge eliminates the round trip to your origin entirely. The edge worker downloads the JWKS once, caches it per-PoP, and validates tokens before the request ever reaches your infrastructure. This is the performance ceiling: authentication latency becomes 0ms added to the request path.
// Cloudflare Worker: validate JWT at the edge
import { jwtVerify, createRemoteJWKSet } from 'jose';
const JWKS = createRemoteJWKSet(
new URL('https://auth.yourapp.com/.well-known/jwks.json')
);
export default {
async fetch(request, env) {
const authHeader = request.headers.get('Authorization');
if (!authHeader?.startsWith('Bearer ')) {
return new Response('Unauthorized', { status: 401 });
}
const token = authHeader.slice(7);
try {
const { payload } = await jwtVerify(token, JWKS, {
issuer: 'https://auth.yourapp.com',
algorithms: ['ES256'],
});
// Forward to origin with validated claims as headers
const modifiedRequest = new Request(request, {
headers: {
...Object.fromEntries(request.headers),
'X-User-ID': payload.sub,
'X-Org-ID': payload.org,
'X-User-Role': payload.role,
},
});
return fetch(modifiedRequest);
} catch {
return new Response('Unauthorized', { status: 401 });
}
}
};
Edge validation is pure JWT — no revocation capability. Use it for APIs where the 5–15 minute JWT lifetime is an acceptable revocation window. For endpoints requiring immediate revocation (logout, account suspension), pass the JWT to the origin and perform an additional revocation check there against the jti blocklist.