JWT is not a broken format. It is a format with a large surface of things you are expected to check yourself, and libraries that will happily let you check none of them. The vulnerabilities are almost never in the crypto.
1. The algorithm is attacker-controlled input
The header travels with the token, which means the sender proposes the algorithm. If verification reads the algorithm from the header, the attacker picks it. Historically that meant alg: none; more usefully today it means switching an RS256 deployment to HS256 and signing with the public key as the HMAC secret — a key the server publishes on purpose.
// wrong: whatever the token says
jwt.verify(token, key);
// right: what the application decided, before it saw the token
jwt.verify(token, key, { algorithms: ["RS256"] });2. Claims are not validated because they parsed
A valid signature proves the token was issued by something holding the key. It does not prove the token was issued for this service, by the issuer you expect, or that it is still current. Those are separate checks, and every one of them is optional in most libraries.
- iss — issued by the party you trust, not another one holding a key you also trust.
- aud — issued for this service. Without it, a token for the low-value sibling service is a token for yours.
- exp and nbf — with a bounded clock skew allowance, not an unbounded one.
- sub — the identity you then look up, rather than a role or permission you read straight out of the token.
3. decode is not verify
Every library ships a decode function that base64-decodes the payload and checks nothing. It exists for logging and debugging. It ends up in authentication paths because it is the one with the shorter name and it works in every test.
const claims = jwt.decode(token); // parses. verifies nothing.
if (claims.role === "admin") { /* you are now whoever you claimed */ }What to test
- 01Strip the signature and send the token with alg set to none, in a few capitalisations.
- 02Re-sign an RS256 token as HS256 using the server's public key as the secret.
- 03Send a token from a sibling environment or service and see whether aud is checked.
- 04Move exp into the past and confirm the rejection is server-side rather than a client-side refresh.
- 05Change the sub or role claim without touching the signature — if it is accepted, verification never ran.
None of this is exotic. It survives because verification looks like it happened: there is a call, it takes the token, it does not throw. The check that is missing is the one nobody wrote a test for, because the happy path passes either way.
