SessionsJWTvalidationalg confusionaudiencekey rotationexpiry

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.

▶ Run the labFollow the failure

Frame the problem

Security starts with a concrete asset, attacker capability and trust crossing.

Asset
The integrity of the verification step. Everything downstream treats verified claims as facts, so a verification flaw is a total authorization bypass.
Attacker & capability
Someone crafting a token they hope your verifier will accept, or replaying a legitimate token somewhere it was never meant to be used.
Trust boundary
The verification function — a boundary that is a handful of lines and is trusted absolutely by everything behind it.
AssetThreatAttack SurfaceTrust BoundaryVulnerabilityExploit PathImpactMitigationDefense in DepthResidual Risk

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.

Verification that accepts more than it should
1import jwt from 'jsonwebtoken'
2
3function verify(token: string) {
4 // 1. algorithm comes from the token's own header
5 // 2. no issuer check 3. no audience check
6 // 4. clock tolerance wide enough to accept long-expired tokens
7 const claims = jwt.verify(token, PUBLIC_KEY, { clockTolerance: 60 * 60 })
8 return claims as Claims
9}
10
11// Elsewhere: routing on unverified data
12const tenant = JSON.parse(atob(token.split('.')[1])).tenant // never verified
13
Verification that pins everything it depends on
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 it
10 issuer: 'https://auth.example', // who minted it
11 audience: 'api.payments.example', // who it is for: this service, specifically
12 clockTolerance: 30, // seconds, for genuine clock skew only
13 maxTokenAge: '10m', // defence in depth against a long-lived exp
14 })
15 if (typeof payload.sub !== 'string') throw new AuthError('missing subject')
16 return payload as Claims // use ONLY these verified claims downstream
17}

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; none is never in it and asymmetric verifiers never fall back to symmetric.
  • kid selects among keys you trust; an unknown kid triggers a rate-limited refresh, then rejection.
  • iss matches the expected issuer exactly, including scheme and host.
  • aud contains this specific service, not a shared organisation-wide value.
  • exp is 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 jti denylist, or a per-user epoch — and has been tested.
  • Authorization headers 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 iss and aud explicitly — 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.

Boundary control check
Untrusted input / identity
Trust boundary
Privileged asset
Prevention may fail silently.

Follow the attack

Safe conceptual simulation: capability → missing control → crossed boundary → asset impact.

  1. 1
    Attacker → inspect the verifier: try a token with `alg: none`, a symmetric algorithm, an unknown `kid`, and a wrong audience.
  2. 2
    Accepted variant → craft claims: set subject, tenant, roles and scopes to whatever is useful.
  3. 3
    Crafted token → request: the service treats forged claims as verified identity.
  4. 4
    Alternatively → replay a legitimate token from a lower-value service against a higher-value one that does not check `aud`.
Blast radius
  • 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.

Prevent
  • • 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.
Detect
  • • 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.
Respond & recover
  • • 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.
Residual risk
  • • 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.

Misconceptions

Claim
“The library verifies the signature, so we are done.”
Reality
A valid signature says the token was issued by someone with the key. It says nothing about whether it was issued for you, for this purpose, or recently.
Claim
“We can decode the payload to route, and verify later.”
Reality
Anything read before verification is attacker-controlled. Routing, logging and authorization must all use the verified object.