Teams often describe JWT validation as a local cryptographic operation. In production it is also a distributed key-distribution protocol: verifiers must discover, cache, rotate, select, and retire public keys without accepting attacker-controlled trust inputs or turning an identity-provider outage into a total platform outage.
The invariant is:
A token is acceptable only when its issuer, key, algorithm, audience, type, and claims all satisfy one locally configured trust policy.
A valid signature proves only that one key signed some bytes. It does not prove the key belongs to an issuer you trust or that the token was intended for your API.
Bind configuration before parsing claims #
The verifier should begin with a local issuer policy:
type IssuerPolicy = {
issuer: string
jwksUri: string
algorithms: readonly ['RS256'] | readonly ['ES256']
audiences: readonly string[]
requiredType: 'at+jwt'
maxTokenAgeSeconds: number
}Do not read an arbitrary jku or x5u header and fetch keys from it. RFC 8725 warns that blindly following attacker-provided URLs can create SSRF. Resolve the JWKS location from trusted issuer configuration or validated authorization-server metadata.
Pin an algorithm allowlist. The token’s alg header is an input to verify, not a command to the verifier. Bind each key to its intended algorithm and reject unexpected or ambiguous combinations.
kid is a selector, not authority #
The JWT kid selects a key from the trusted issuer’s JWK Set. It must never select an issuer, build a file path, become an unparameterized database query, or cause an unbounded network fetch.
Normal rotation looks like:
JWKS publishes old + new key
-> issuer begins signing with new kid
-> verifiers already know both keys
-> old tokens expire
-> old key is removedPublishing the new verification key before using it avoids a fleet-wide miss at the signing cutover. Retaining the old key until every legitimately issued token expires avoids rejecting valid sessions.
Cache for availability without freezing trust #
Fetching JWKS on every request makes authentication latency depend on the identity provider and amplifies an outage into a request storm. Cache the set according to bounded policy and HTTP caching signals, but retain controls for emergency refresh and revocation.
A safe unknown-kid path is single-flight and rate-limited:
async function resolveKey(issuer: IssuerPolicy, kid: string) {
const cached = keyCache.get(issuer.issuer, kid)
if (cached)
return cached
await refreshSingleFlight(issuer.issuer, {
minRefreshIntervalMs: 30_000,
deadlineMs: 1_000,
})
return keyCache.get(issuer.issuer, kid) ?? null
}Without a minimum refresh interval, an attacker can send random key IDs and force outbound requests. Without single-flight, one legitimate rotation can make every instance refresh simultaneously.
Keep a last-known-good set through a short provider outage, but do not extend token expiration or accept keys past an explicit revocation policy merely because refresh failed. Availability and revocation have different priorities for different systems; document the choice.
Validate the semantic envelope #
After cryptographic verification, validate at least:
- exact issuer match;
- intended audience;
- expiration and not-before with a small, bounded clock tolerance;
- expected token type and mutually exclusive validation rules for different token kinds;
- subject requirements for the endpoint;
- scopes or permissions using server-side policy;
- maximum token age where compromise exposure demands it.
Explicit typing reduces token confusion. An ID token, access token, email-verification token, and internal job token should not be interchangeable merely because they share an issuer and signing key.
Do not authorize from mutable human labels such as an email domain alone. Translate validated identity into product permissions through an owned authorization model.
Rotation and emergency revocation are different #
Routine rotation is an overlap protocol. Emergency revocation is a containment event. If a private key may be compromised, leaving its public key available until all tokens expire preserves attacker access.
Your incident plan should answer:
- How quickly can the issuer stop signing with the key?
- How quickly do verifiers refresh?
- Can a key be denylisted before cache expiry?
- What is the maximum remaining token lifetime?
- Which services accept the affected issuer and audience?
- Can high-risk operations require fresh introspection or step-up authentication?
Short-lived access tokens reduce exposure but increase dependence on refresh infrastructure. Longer tokens improve outage tolerance but enlarge the compromise window. That is a product-security trade-off, not a library default.
Observe without leaking credentials #
Record issuer, audience-validation outcome, algorithm, key ID, policy version, cache hit or refresh, rejection reason, and verifier latency. Never log the raw token. Hash identifiers only when the operational need and retention policy justify it.
Alert on:
- sudden unknown-
kidvolume; - JWKS refresh failures and last-success age;
- algorithm or issuer mismatches;
- token-expiry failures by client version;
- one key remaining active beyond the rotation window;
- abnormal verification latency or refresh concurrency.
Test rotations with old and new tokens across every service. Test issuer downtime, stale caches, random key IDs, duplicate keys, clock skew, wrong audience, wrong token type, and emergency denial.
The CTO decision #
Treat JWT verification as shared security infrastructure with a versioned trust policy, not middleware copied into every repository. Centralize the hard rules while keeping applications responsible for product authorization.
The signature check is the smallest part. The system succeeds when key rotation is boring, outages are bounded, untrusted headers cannot redirect trust, and every accepted token is proven to belong to the exact context in which it is used.