JWT attacks and hardening

OAuth, OIDC & JWT · Expert · 15 min

Many real breaches came from libraries or code that trusted attacker-controlled JWT headers.

A JWT header is attacker-controlled input. The classic attacks all exploit code that lets the header decide how to verify:

  • `alg: none`: an unsigned token accepted by a library that honours the header. Never accept none.
  • Algorithm confusion: a server expects RS256 but the attacker sends HS256, and a naive library uses the RSA public key bytes as the HMAC secret. Since the public key is public, the attacker can sign anything. Fix: bind each key to one algorithm and pin allowed algorithms.
  • `kid` injection: using kid in a file path or SQL query to load the key. Treat kid only as a lookup key into your own key set.
  • `jku` / `x5u` / embedded `jwk` headers: fetching keys from a URL the token itself names lets attackers supply their own keys. Ignore these unless restricted to an allow-list.
  • Cross-JWT confusion: using a token of one kind as another, e.g. an ID token as an access token, or a token for service A at service B. Check typ, aud and iss.
  • Weak HMAC secrets can be brute-forced offline from a single token; use at least 256 random bits, or asymmetric keys.

RFC 8725 (JWT BCP) summarizes the defences: pin algorithms, validate all relevant claims, use explicit typing (typ), and use different keys for different token kinds. Prefer asymmetric signatures when many services verify, so only the issuer can mint tokens.

Example

import { SignJWT, jwtVerify, generateSecret, UnsecuredJWT } from "jose";

const secret = await generateSecret("HS256");
const opts = { algorithms: ["HS256"], issuer: "auth", audience: "api" };

// alg=none token: jose rejects it because 'none' is not an allowed algorithm
const unsigned = new UnsecuredJWT({ sub: "admin" }).setIssuer("auth").setAudience("api").encode();
await jwtVerify(unsigned, secret, opts).catch((e) => console.log(e.code));
// ERR_JOSE_ALG_NOT_ALLOWED

// right key, wrong audience (token for another service)
const other = await new SignJWT({ sub: "u1" }).setProtectedHeader({ alg: "HS256" })
  .setIssuer("auth").setAudience("billing").setExpirationTime("5m").sign(secret);
await jwtVerify(other, secret, opts).catch((e) => console.log(e.code, e.claim));
// ERR_JWT_CLAIM_VALIDATION_FAILED aud

Remember

  • The header is attacker input: pin algorithms, never trust alg
  • Bind each key to one algorithm to stop RS/HS confusion
  • Ignore jku/x5u/jwk headers; look kid up in your own key set
  • Use typ and aud to prevent cross-JWT confusion

Watch out for

  • Writing your own JWT verification instead of using a maintained library with algorithm pinning.

Check yourself

  1. In an algorithm confusion attack, what does the attacker use as the HMAC secret?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: www.rfc-editor.org/rfc/rfc8725