JWT: alg confusion, kid injection, and the day Java accepted a signature of zero
A JSON Web Token is only as good as the verification code. Five failure modes with concrete examples, including CVE-2022-21449, where an all-zero ECDSA signature validated against any key on Java 15 through 18.
8 min read2 views
A JWT is three base64url segments: a header saying how it was signed, a payload of claims, and a signature.
Nearly every serious JWT vulnerability comes from the same structural fact: the token tells the server how to verify it. The attacker writes the header. The server reads the header to decide what to do. Every failure below is a variation on trusting that instruction.
1. alg: none
The specification defines an "unsecured JWT" with alg set to none and an empty signature. Some libraries accepted it by default.
{"alg": "none", "typ": "JWT"}
Strip the signature, set alg to none, change "role":"user" to "role":"admin", send it. If the verifier honours the header, the token validates because the algorithm for verifying nothing is to do nothing.
Most libraries fixed this years ago, and the modern version is subtler. Look for none, None, NONE, nOnE where the comparison is case-sensitive. Look for verification helpers that accept an empty algorithm list. And look for the two-step pattern where code calls a decode function to read claims before verifying, plenty of applications make a trust decision on decoded-but-unverified claims and never notice.
It also still ships. CVE-2026-23993, disclosed against HarbourJwt in January 2026, is the sharper version of the lesson: the library did allowlist HS256, HS384 and HS512. But its signing routine returned an empty string for anything outside that list, and recorded the error without acting on it. Send alg: zzz with an empty signature segment and the comparison becomes "" == "". The allowlist was correct; the failure path was not. An algorithm check that does not hard-abort is not a check.
2. Algorithm confusion (HS256 / RS256)
The one that still lands in real assessments, and still ships in new code. Node's jsonwebtoken had it as CVE-2015-9235. In January 2026, Hono's JWK/JWKS middleware had it as CVE-2026-22817: the middleware took the verification algorithm from the JWK where the JWK declared one, and fell back to the token's header where it did not. alg is optional in a JWK and routinely omitted, so the fallback was the common path. Eleven years apart, in different languages, the same decision.
RS256 is asymmetric: sign with the private key, verify with the public key. HS256 is symmetric: one secret both signs and verifies. A verification function that takes "the key" and reads the algorithm from the header can be persuaded to use the public key as an HMAC secret.
The attack:
- Obtain the public key. It is public,
/.well-known/jwks.json, the OIDC discovery document, the TLS certificate, the API documentation, or a GitHub repository. - Change the header to
{"alg": "HS256"}. - Sign the token with HMAC-SHA256, using the public key bytes as the secret.
- The server reads
alg: HS256, fetches "the key". The public key, and verifies the HMAC. It matches, because you used the same value.
Where no key is published, it can often be derived instead: two valid RSA-signed tokens from the same key are enough to recover the public modulus with public tooling. Restricting the JWKS endpoint narrows the attack surface without closing it, so record it as hardening, not as the fix.
The fix is one line and it is not "validate the header": specify the expected algorithm at the call site.
# Fragile: the token chooses
jwt.decode(token, key)
# Correct: the server chooses, and the token must comply
jwt.decode(token, key, algorithms=["RS256"])
Getting the exact key bytes right (PEM headers, trailing newlines, DER versus PEM) is fiddly, which is why this is sometimes reported as unexploitable when it is merely awkward. Do not accept "we tried and it did not work" as a negative result unless the verification code has been read.
3. kid injection
The kid (key ID) header tells the server which key to use. If the server interpolates it into a filesystem path or a SQL query, it is an injection point that happens to be inside a security-critical function:
{"alg": "HS256", "kid": "../../../../dev/null"}
If kid selects a file and the attacker can point it at a file with known contents, they can sign tokens with that content as the HMAC secret. /dev/null is the classic because it yields an empty key. The SQL variant is worse: a UNION SELECT returning a value you control lets you choose the signing key directly.
Also treat jku and x5u as hostile. Both name a URL the server should fetch keys from. If unrestricted, the attacker hosts their own key set, signs with the matching private key, and the server obligingly downloads the means to verify it. If your library supports them and your application does not need them, disable them; if you need them, allowlist exact origins, never a suffix match.
4. Claims that are never checked
Signature verification succeeding does not mean the token is valid for this request:
exp, is expiry actually enforced? Libraries usually check it; custom verification code frequently does not.aud, was this token issued for your service? In a multi-service estate, a valid token from a sibling service is a valid signature and the wrong audience. This is how one compromised low-value service becomes access to a high-value one.iss, did it come from your issuer?nbf, not-before.subvs the object being accessed. The token identifies user A and the request asks for user B's data. That is BOLA, and no amount of correct cryptography addresses it.
Multi-tenant systems should also check a tenant claim against the requested resource. A perfectly valid token from tenant X used against tenant Y's data is a cross-tenant breach with a flawless signature.
5. CVE-2022-21449, "Psychic Signatures"
This is the one to remember, because it is the failure mode you cannot defend against with good application code.
ECDSA verification involves checking an equation with the signature values r and s. The specification requires both to be in the range 1 to n-1. Specifically, neither may be zero, because if both are zero the equation collapses to 0 = 0, which is true for every message and every key.
Java's rewritten ECDSA implementation, introduced in Java 15, did not check that r and s were non-zero.
The consequence: a signature consisting entirely of zeroes validated against any public key, for any message. Not a weakness. A total bypass, with a blank signature. It affected Java 15, 16, 17 and 18, and reached anything relying on ECDSA on those runtimes: signed JWTs, SAML assertions, OIDC ID tokens, WebAuthn messages, some TLS handshakes.
Neil Madden, who reported it, made the point that sticks: this was the sort of check a specification states explicitly and an implementation quietly dropped during a rewrite, in this case, when the implementation was moved from C++ to Java. Your application code was correct. Your library was correct. Your token was verified by a runtime that considered nothing to be a valid signature.
The transferable lesson is about where your trust actually rests. A JWT's security is the product of the specification, the library, and the cryptographic provider underneath. Most threat models stop at the first two. Keep runtime patching in scope for authentication systems, and prefer widely-used, well-audited crypto implementations over novel ones, including novel rewrites of old ones.
Testing
- Decode first.
jwt.io, or justbase64 -deach segment. Read the header. Note the algorithm,kid,jku,x5u. - Set
algtonone(and its case variants) with an empty signature. - If RS256, fetch the public key and attempt HS256 confusion.
- Path- and SQL-inject
kid. - Point
jkuat a host you control and see whether the server fetches it. An outbound DNS lookup alone confirms it. - Change claims without touching the signature, and confirm you get a clean rejection rather than a
500. - Replay an expired token. Replay a token from a different service in the same estate. This is where
audfailures surface. - Change
sub/user_idto another user's and check the authorisation result separately from the signature result. - If HS256, test for a weak secret offline. Default secrets from tutorials and sample repositories are alarmingly common.
- Check whether the token is accepted from the wrong place. A query string, a cookie without
HttpOnly, a header the application also logs.
Fixing it
- Pin the algorithm at the call site. Always a list you wrote, never the header's suggestion.
- Disable
jkuandx5uunless required; allowlist exact origins if they are. - Never use
kidas a path, filename or SQL fragment. Map it through a lookup table of known key IDs. - Verify
exp,aud,iss, and tenant. Explicitly, in code you can point to. - Rotate keys, and support multiple valid keys during rotation so nobody is tempted to skip it.
- Prefer EdDSA (Ed25519) for new systems. Fewer parameters, fewer implementation choices, fewer places to omit a range check.
- Patch the runtime. CVE-2022-21449 is fixed in the April 2022 Java updates.
- Use short expiry with refresh tokens. JWTs are not revocable on their own; expiry is your only revocation mechanism unless you add a deny list.
Detection
- Tokens whose header algorithm differs from your issuer's, log the observed
algon every verification failure and success. - Any token presenting
alg: none, an algorithm you never issue, orkidcontaining../,', or a null byte. - Outbound HTTP from your auth service to a host that is not your key provider, that is
jkubeing followed. - A spike in verification failures from one client: someone is iterating.
- Tokens accepted with an
audthat is not you. This should be impossible, so alert rather than log.
Take this away
Every one of these failures is the same mistake: the server let the token decide how it would be verified.
The token is attacker-controlled input. The header is attacker-controlled input. The only values a verifier should trust are the ones it was configured with before the request arrived.
Further reading
Was this useful?
Comments
Loading comments…