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
RS256but the attacker sendsHS256, 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
kidin a file path or SQL query to load the key. Treatkidonly 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,audandiss. - 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 audRemember
- 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
kidup in your own key set - Use
typandaudto prevent cross-JWT confusion
Watch out for
- Writing your own JWT verification instead of using a maintained library with algorithm pinning.