JWT Failure Modes
The specific ways token validation goes wrong — trusting the header's algorithm, skipping issuer and audience, over-long expiry, treating signed as confidential — and the verification routine that closes all of them.
Frame the problem
Security starts with a concrete asset, attacker capability and trust crossing.
The classic mistakes
Trusting `alg` from the token. The header declares the algorithm, and a naive verifier reads it and uses the corresponding method. That lets an attacker choose. Historically the notorious cases were declaring none (asserting the token needs no signature) and algorithm confusion, where a verifier configured for RSA is handed a token declaring HMAC and helpfully uses the RSA *public* key — which is public — as the HMAC secret. The fix is structural: decide the acceptable algorithms in your code, and reject any token whose header does not match. Never let the token influence how it is verified.
Not checking `aud` and `iss`. A token minted by your identity provider for the analytics service is a perfectly valid, correctly signed token. If your payments API verifies the signature and stops, it will accept it. Audience checking is what makes a token usable in one place and not another, and it is the check most commonly missing in service-to-service setups where "we all trust the same issuer" quietly became "any token works anywhere".
Accepting unverified claims for convenience. Reading the payload before verifying — to route by tenant, to pick a key, to log the subject — is a common shortcut and is fine only for values that are re-checked afterwards. The failure mode is subtle: code that decodes to select a tenant, then verifies, then uses the *decoded* tenant rather than the verified one.
Over-long expiry. A token valid for thirty days is a thirty-day credential with no revocation. This is usually chosen to avoid implementing refresh, and it converts every token leak into a month-long incident.
1import jwt from 'jsonwebtoken'2 3function verify(token: string) {4 // 1. algorithm comes from the token's own header5 // 2. no issuer check 3. no audience check6 // 4. clock tolerance wide enough to accept long-expired tokens7 const claims = jwt.verify(token, PUBLIC_KEY, { clockTolerance: 60 * 60 })8 return claims as Claims9}10 11// Elsewhere: routing on unverified data12const tenant = JSON.parse(atob(token.split('.')[1])).tenant // never verified13 1import { createRemoteJWKSet, jwtVerify } from 'jose'2 3// Keys are fetched from the issuer's JWKS and cached; rotation is automatic,4// and 'kid' selects among *trusted* keys rather than deciding the algorithm.5const jwks = createRemoteJWKSet(new URL('https://auth.example/.well-known/jwks.json'))6 7export async function verify(token: string): Promise<Claims> {8 const { payload } = await jwtVerify(token, jwks, {9 algorithms: ['RS256'], // OUR list — the header cannot widen it10 issuer: 'https://auth.example', // who minted it11 audience: 'api.payments.example', // who it is for: this service, specifically12 clockTolerance: 30, // seconds, for genuine clock skew only13 maxTokenAge: '10m', // defence in depth against a long-lived exp14 })15 if (typeof payload.sub !== 'string') throw new AuthError('missing subject')16 return payload as Claims // use ONLY these verified claims downstream17}The hardened version decides the algorithm, the issuer and the audience in code, so nothing in the attacker-supplied token can influence how it is checked. Everything downstream uses the verified payload rather than a decoded one.
Key management and rotation
Signing keys need the same lifecycle as any other secret: generated securely, stored in a secret manager or HSM, rotated on a schedule, and revocable. The kid header exists to make rotation possible — the verifier looks up the key by id from a trusted key set, so old and new keys can coexist during a transition.
The failure modes here are operational rather than cryptographic. A single key that has never been rotated means a compromise has no bounded remediation and rotation has never been tested. Fetching the key set without caching makes every verification depend on the issuer's availability, which turns an identity-provider blip into a total outage. Caching without a refresh path means rotation breaks every verifier at once. And accepting any key from a JWKS endpoint that is fetched over an unauthenticated or attacker-influenceable URL undermines everything.
The workable pattern: fetch the JWKS over HTTPS from a pinned issuer URL, cache it with a TTL, refresh on encountering an unknown kid (with a rate limit so an attacker cannot force fetches), and keep serving from cache if the fetch fails. Rotate on a schedule, keep the previous key valid for at least one maximum token lifetime, and rehearse the rotation before you need it during an incident.
A validation checklist worth keeping
This is the list to walk during a review of any service that consumes tokens. Each item corresponds to a real, repeatedly-observed failure.
- Algorithms come from your allow-list;
noneis never in it and asymmetric verifiers never fall back to symmetric. kidselects among keys you trust; an unknownkidtriggers a rate-limited refresh, then rejection.issmatches the expected issuer exactly, including scheme and host.audcontains this specific service, not a shared organisation-wide value.expis checked with a small clock tolerance (seconds), and a separate maximum age bounds the lifetime independently.- Only the verified payload is used; nothing routes, authorizes or logs from a pre-verification decode.
- No sensitive data in claims, because a token holder reads all of them.
- A revocation mechanism exists — refresh tokens, a
jtidenylist, or a per-user epoch — and has been tested. Authorizationheaders are redacted in logs, traces and error reports.
Key points
- Never let the token choose its own verification algorithm; the allow-list lives in your code.
- Check
issandaudexplicitly — a valid token for another service is still a valid token. - Use only the verified payload; a pre-verification decode is attacker-controlled data.
- Bound token age independently of
exp, and keep clock tolerance to seconds. - Rotate signing keys on a schedule with
kid-based key sets, and rehearse the rotation before you need it.
Boundary control exercise
This lesson uses the shared boundary-control exercise.
Follow the attack
Safe conceptual simulation: capability → missing control → crossed boundary → asset impact.
- 1Attacker → inspect the verifier: try a token with `alg: none`, a symmetric algorithm, an unknown `kid`, and a wrong audience.
- 2Accepted variant → craft claims: set subject, tenant, roles and scopes to whatever is useful.
- 3Crafted token → request: the service treats forged claims as verified identity.
- 4Alternatively → replay a legitimate token from a lower-value service against a higher-value one that does not check `aud`.
- Complete authentication and authorization bypass: the attacker chooses their own identity and permissions.
- Cross-service replay reaches systems that were never in the token's intended scope.
- The forged requests look entirely legitimate in logs, because verification "succeeded".
Defend, detect, recover
One prevention is a single point of security failure. Layer it and make failure observable.
- • Use a maintained JOSE library with explicit `algorithms`, `issuer` and `audience` options; do not hand-roll verification.
- • Give each service a distinct audience value rather than one shared across the organisation.
- • Keep access tokens short-lived and revocation-capable via refresh tokens or an epoch claim.
- • Add a test asserting that tokens with wrong `alg`, wrong `aud`, wrong `iss` and expired `exp` are all rejected.
- • Alert on any token rejected for `alg`, `iss` or `aud` mismatch — these are near-zero in a healthy system.
- • Alert on unknown `kid` values, which indicate either a rotation problem or a forgery attempt.
- • Log `jti` and issuer for accepted tokens so cross-service replay is visible after the fact.
- • Rotate the signing key immediately if forgery is suspected; this invalidates all outstanding tokens.
- • Audit actions performed with tokens whose claims cannot be corroborated against issuer records.
- • Add the missing check and deploy it to every consumer, not only the one where it was found.
- • A legitimately-issued token that is stolen passes every check by design.
- • Key rotation windows require accepting old keys briefly, which is a period of reduced containment.
- • Verification correctness must hold in every consumer; one service with a lax check undermines the boundary for all of them.