Pin the algorithm: JWT confusion bugs that still ship

How trusting the alg header lets attackers forge tokens, and the few lines of configuration that prevent it.

JSON Web Tokens carry their own instructions for how to verify them. The alg field in the header tells the recipient which algorithm was used to sign the token. If your code believes that header, an attacker gets to choose how their forged token is checked.

A token that describes itself

A JWT has three base64url-encoded parts: header, payload and signature. A typical header looks like this:

{ "alg": "RS256", "typ": "JWT", "kid": "2026-05" }

Nothing protects the header until the signature is verified — and the signature is verified with the algorithm the header names. That circularity is the root of two classic bugs.

Bug 1: alg: none

The specification defines an "unsecured" token with "alg": "none" and an empty signature. A verifier that dispatches on the header will accept this as a valid token for the admin user:

eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJhZG1pbiJ9.

Maintained libraries reject this by default today. We still find it in hand-written middleware and in thin internal wrappers that pass the header's algorithm straight into a lower-level verify function.

Bug 2: RS256 → HS256 confusion

Your API issues tokens signed with RS256: a private key signs, the public key verifies. The public key is public by design — often served from a JWKS endpoint.

Now look at verification code like this:

// Vulnerable: the algorithm comes from the token
const { alg } = decodeHeader(token);
verify(token, publicKeyPem, alg);

An attacker downloads your public key, creates a token with "alg": "HS256", and signs it with HMAC using the public key bytes as the secret. Your server reads HS256 from the header, runs HMAC with the key it was given — that same public key — and the signature matches. The attacker can now mint tokens for any user.

The fix: the server decides the algorithm

Never let the token choose. Configure the expected algorithm explicitly.

Python (PyJWT)

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

Node.js (jsonwebtoken)

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

Go (golang-jwt v5)

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

If you rotate keys, map each kid to both a key and its algorithm on the server side. A key should only ever be usable with one algorithm.

Other header fields to distrust

  • kid is attacker-controlled input. We've seen it concatenated into file paths (../../dev/null) and SQL queries. Resolve it through a fixed lookup table; never interpolate it.
  • jku and x5u point to a URL that hosts keys. If your verifier fetches whatever URL the token names, the attacker simply hosts their own key. Ignore these headers, or pin them to an allowlist of exact URLs.
  • jwk embeds a public key directly in the header. A token that brings its own key proves nothing.

A valid signature is not the end

The signature only proves who issued the token. You still need to check the claims:

Claim Why it matters
exp, nbf Stolen tokens should stop working
aud A token issued for another service must not work on yours
iss Only accept tokens from the issuer you actually trust

Checklist

  • Hard-code allowed algorithms in the verifier; never read them from the token.
  • Bind each key to exactly one algorithm.
  • Resolve kid through a fixed lookup; ignore jku, x5u and jwk.
  • Validate exp, aud and iss on every request.
  • Search the codebase for decode calls that skip verification "just to read the user ID".

Want a second pair of eyes on your code? Get in touch.

← Parameterized queries don't cover ORDER BY Your webhook signature check is probably comparing strings wrong →