The JWT Library That Accepted alg none for Four Months

Share
The JWT Library That Accepted alg none for Four Months. Abstract security illustration in orange and dark grey on debugly.dev

A penetration test report landed with a finding rated critical. The tester had taken a valid token from their own low privilege account, changed the payload, set the header algorithm to none, stripped the signature, and been granted access to another tenant's data.

We had read the advisories. We knew alg: none was the oldest JWT trick in the book. It did not occur to any of us that our own verification call was the vulnerable thing, because we were not signing with none.

We were verifying with whatever the token said.

This was Node 22.14 with jsonwebtoken 9.x, and a Go 1.23 service using a similar pattern. The mechanics are library independent.

How the attack works

A JWT is three base64url parts: header, payload, signature. The header declares the algorithm:

{ "alg": "HS256", "typ": "JWT" }

If your verification code reads alg from the token and then dispatches on it, the attacker controls the dispatch. Set alg to none, delete the signature, and a library that implements the spec literally will verify the token as unsigned and valid.

The none case is now blocked by most libraries by default. The variant that still works, and the one that gets people in production, is key confusion.

Key confusion, which is the one that still works

If your service signs with RS256, it holds a private key and publishes a public key. Verification uses the public key.

RSA public keys are not secret. They are usually served at /.well-known/jwks.json to anyone who asks.

Now: HS256 is a symmetric algorithm. Its "key" is a shared secret. If a library lets the token choose between RS256 and HS256, and you pass your RSA public key as the verification key, then an attacker can:

  1. Take your public key, which they downloaded.
  2. Forge a token with any payload they like.
  3. Sign it with HS256 using your public key as the HMAC secret.
  4. Set alg: HS256 in the header.

Your library sees HS256, uses the key you gave it, computes the HMAC, and it matches. Because the key you gave it is exactly the key they signed with.

The token is cryptographically valid. That is the disturbing part. Nothing failed. Nothing looked like an attack. A correctly computed signature verified against a correctly configured key.

The fix, which is one line

Never let the token choose the algorithm. Declare it at the verification site.

Node:

const payload = jwt.verify(token, publicKey, {
  algorithms: ["RS256"],
  issuer: "auth.example.com",
  audience: "api.example.com",
});

Without algorithms, jsonwebtoken will accept what the token claims. With it, an alg: HS256 token is rejected before any cryptographic work happens.

Go, with golang-jwt:

token, err := jwt.Parse(raw, keyFunc,
    jwt.WithValidMethods([]string{"RS256"}),
    jwt.WithIssuer("auth.example.com"),
    jwt.WithAudience("api.example.com"),
)

Python, PyJWT:

payload = jwt.decode(token, public_key,
                     algorithms=["RS256"],
                     issuer="auth.example.com",
                     audience="api.example.com")

PyJWT raised a InvalidAlgorithmError for unspecified algorithms starting in 2.x, which is the right default, but pin it anyway so a dependency upgrade cannot change your security posture.

The other four things to pin

Algorithm confusion is the headline finding, but it is rarely alone. While you are in there:

Issuer and audience. Without these, a token minted for your staging environment or your mobile app verifies against your production API. This is the same class of error as OAuth redirect URI mismatch, where the flow is correct and the scoping is not.

Expiry, and clock skew. Check exp. If you need to tolerate clock drift, use a small explicit leeway rather than disabling the check. JWT expired and clock skew covers debugging the failures that produce.

Key ID selection. If you fetch keys from a JWKS endpoint and select by kid, make sure an unknown kid fails closed. A common bug is falling back to the first key in the set, which turns a key rotation into an authentication bypass.

The typ header. Less commonly exploited, but if your service accepts both JWTs and another token format, an attacker may be able to get a parser to interpret one as the other.

How to find out whether you are affected

Grep for verification calls and check each one for an explicit algorithm list:

grep -rn "jwt.verify\|jwt.decode\|jwt.Parse\|ParseWithClaims" --include="*.js" --include="*.ts" --include="*.py" --include="*.go" .

Then test it. Forge a token with alg: none and no signature, and one signed HS256 with your public key. Send both at an authenticated endpoint. If either returns anything other than 401, you have the bug.

HEADER=$(printf '{"alg":"none","typ":"JWT"}' | openssl base64 -A | tr '+/' '-_' | tr -d '=')
PAYLOAD=$(printf '{"sub":"attacker","iss":"auth.example.com"}' | openssl base64 -A | tr '+/' '-_' | tr -d '=')
curl -s -o /dev/null -w "%{http_code}\n" \
  -H "Authorization: Bearer ${HEADER}.${PAYLOAD}." \
  https://api.example.com/me

A 401 is what you want. Anything else is an incident.

The JWKS rotation trap

While you are auditing verification, check what happens when a kid in a token does not match any key you have cached. During a key rotation there is a window where the identity provider has published a new key and old tokens still carry the old kid. The correct behaviour is to refetch the JWKS once, and if the kid is still unknown, reject the token.

The incorrect behaviour, which I have seen in production twice, is to fall back to the first key in the set. That turns every rotation into a window where tokens signed by any key you publish will verify. Cache the key set with a short TTL, refetch on an unknown kid at most once per token, and fail closed after that.

Why this survived four months

Because the code looked right. There was a verification call, a public key, and a library with a good reputation. The defect was an absent argument, and absent arguments do not show up in review the way present mistakes do.

The lesson I took is narrower than "audit your JWT handling". It is that security properties which depend on a default are not properties. They are the current behaviour of a dependency, and they will change. Every one of the five settings above should be explicit at the call site, so that reading the verification line tells you the whole policy without reading the library's changelog.

If you handle tokens at all, run the two forged token tests today. They take five minutes and they answer the question definitively.