Every one of these has a CVE behind it, most of them several. They share a single root cause: verification code that trusts something the attacker sends.
alg: none, the original hole
RFC 7518 defines "none" as a legal value for the alg header. It means the JWS is unsecured: no signature, empty third segment. The intent was tokens whose integrity is guaranteed by the transport, which is a narrow and defensible use case. The consequence, once libraries implemented it, was that an attacker could take any token, rewrite the header to {"alg":"none"}, edit the payload to say "role":"admin", drop the signature and be let straight in.
Tim McLean published this in March 2015 against several popular libraries, and it became CVE-2015-9235. The libraries were fixed. The pattern did not go away, because the bug is not really in the library: it is in a verify call that accepts whatever algorithm the token asks for. Ten years later the same class of finding still lands in bounty programmes, usually in hand-rolled middleware or an internal service that parses the token itself "just to read the user id" and then forgets that reading is not verifying.
One rule kills it for good. Pass an explicit algorithm list to every verify call, and treat any token whose alg is not in that list as a hard failure with no fallback path. If you want to see what an unsecured token actually looks like, our JWT decoder shows the header and payload of any token you paste, including the ones with a missing signature, and deliberately never asks for your signing secret.
RS256 to HS256, or how a public key becomes a password
This one is more interesting than alg: none, and it survives in codebases that already patched the first mistake.
Your service signs tokens with RS256: private key signs, public key verifies. The public key is, by definition, not a secret. It sits in your JWKS endpoint at /.well-known/jwks.json, or in a config file, or in a repository.
The attacker changes the header to {"alg":"HS256"}, then computes an HMAC-SHA256 over the token using the PEM text of your public key as the HMAC key. Now consider what a naive verify function does: it reads alg, sees HS256, looks up "the key" for this issuer, gets the public key, runs HMAC with it, and the signature matches. The public key was never meant to be a secret, and now it is being used as one.
The fix is the same allowlist as above, plus a keying rule: symmetric and asymmetric keys must never be reachable through the same lookup. In practice that means the verify call names the algorithm, and the key material is selected by that algorithm rather than by anything in the token.
| Library | Safe call |
|---|---|
| jsonwebtoken (Node) | jwt.verify(token, pub, { algorithms: ['RS256'] }) |
| PyJWT | jwt.decode(token, key, algorithms=['RS256'], audience=..., issuer=...) |
| golang-jwt | jwt.Parse(t, keyFn, jwt.WithValidMethods([]string{'RS256'})) |
| java-jwt (Auth0) | JWT.require(Algorithm.RSA256(pub, null)).build().verify(t) |
PyJWT is worth a note: it has required the algorithms argument since 2.0, which is the right default and a good example of a library making the safe path the only path. It still had to ship CVE-2022-29217 in version 2.4.0 for a key confusion case that slipped through, which tells you how narrow the margins are here.
Secrets that a laptop cracks over lunch
With HS256 the signature is an HMAC, so the secret is the whole security model. HMAC verification is offline and unlimited: an attacker with one valid token can test guesses locally, forever, with no rate limit and nothing in your logs.
hashcat has had a JWT mode since 2017 (-m 16500). On a single current GPU it runs in the billions of candidates per second against a wordlist. Every secret in this list has been recovered that way in real assessments: secret, changeme, the company name, the repository name, the string from the tutorial the code was copied from, and the JWT sample key your-256-bit-secret that ships in more than one getting-started guide.
RFC 7518 requires an HMAC key at least as long as the hash output, so 256 bits for HS256. Take that literally: 32 bytes out of a CSPRNG, base64 it, put it in the environment, and never let a human choose it. Our password generator will produce a 44-character random string entirely in your browser if you need one right now, and the HMAC generator is useful for the other half of the job, checking by hand what a given secret and payload should produce when a signature refuses to match.
One thing that costs nothing: use a different secret per environment. Staging secrets leak, and a shared secret means a staging leak is a production compromise.
The claims nobody validates
Signature verification answers one question: was this token issued by someone holding the key. It says nothing about whether the token was meant for you, for this user, or for right now. Those are the registered claims from RFC 7519, and skipping them is the most common finding of the five.
- exp is usually checked, because libraries check it by default. Watch the leeway setting: a generous clock skew allowance of an hour, added because of one flaky CI box, extends every token by an hour.
- aud is usually not checked. If your identity provider mints tokens for five services against the same key, a token for the least protected of them verifies perfectly at the most protected one. This is the confused deputy in its purest form, and the fix is one verify option.
- iss is usually not checked either. It matters as soon as your verification trusts more than one key, which is the case the moment you cache a JWKS document.
- sub is checked but often trusted too far. It identifies the principal; it does not carry authorisation. Roles inside a token are a snapshot from issue time, so a user demoted five minutes ago still has the old role in a token that lives fifteen.
- nbf matters mostly in the negative: reject tokens that are not yet valid rather than ignoring the claim, otherwise a pre-dated token is a scheduled backdoor.
The general point holds beyond JWT. A signature proves origin, not intent, and short expiry is the only thing that makes a stale role claim survivable, which is also the reason we came down where we did in localStorage vs cookies.
jku, x5u, kid: headers that make your server fetch things
JWS headers can carry key locations. jku is a URL to a JWK Set, x5u a URL to an X.509 certificate chain, kid an identifier for picking a key out of a set. All three are attacker-controlled, since they arrive inside the token.
The jku attack writes itself: point it at the attacker's server, serve a JWK Set containing a key they generated, sign the token with the matching private key. A verifier that fetches the URL from the header verifies it correctly and lets them in as whoever they claimed to be. If you support jku at all, the URL must be checked against an exact allowlist of hosts and paths before any fetch happens, and "starts with our domain" is not a check: https://issuer.example.com.evil.tld/keys starts with it too.
kid is subtler because it usually ends up in a lookup rather than a fetch. Two shapes recur. If the value is used as a filename, {"kid":"../../../../dev/null"} gives an empty key, and on some libraries an empty HMAC key means the attacker can sign tokens with the empty string. If the value goes into a SQL query, it is a plain injection point that returns whatever key the attacker's UNION selects. Treat kid as untrusted input: exact match against known key ids, no path building, no string concatenation into queries.
Our JWT generator will produce tokens with whatever header you configure, which is the fastest way to check how your own verification reacts to an unexpected kid or an unfamiliar alg before someone else checks it for you.
You cannot log anyone out
This is not a bug, it is the design, and it still surprises teams during an incident. A JWT is valid because the math says so. There is no lookup, no session row, nothing to delete. A stolen token works until exp, and "log out all devices" cannot be implemented with tokens alone.
Three workable answers, in ascending order of effort:
- Short access tokens plus a refresh token. 5 to 15 minutes for the access token, a refresh token stored server-side and revocable. This is the standard answer and it caps the damage window without a lookup on every request.
- A token version per user. One integer column, copied into the token as a claim, compared on verification, incremented on logout or password change. One cheap read per request, and it invalidates every token for that user instantly.
- A denylist of jti values in Redis, each entry expiring when the token would have. Precise, and it costs you the stateless property you adopted JWT for in the first place.
Worth saying plainly: if you are running a single application with one database, session cookies are the simpler and more secure choice. JWT earns its complexity when several independent services need to verify without calling home. The useful question is which of those two you actually are, not which token format you would rather use.
When the library is the bug
Two worth knowing, because they are not implementation mistakes on your side.
CVE-2022-21449, the "psychic signatures" bug that Neil Madden disclosed in April 2022, made Java 15 through 18 accept an ECDSA signature where both r and s were zero. Any ES256 token with a blank signature verified. The affected code was the JDK itself, so every JWT library on those versions inherited it, and the fix was a JDK update rather than a dependency bump.
CVE-2022-23529 in Node's jsonwebtoken below 9.0.0 allowed remote code execution when an attacker could influence the key passed to verify(). That is an unusual precondition, but it is exactly the precondition created by a kid-driven key lookup, which is how the two halves of this article meet.
Keep JWT libraries current, and prefer the ones that force you to name the algorithm.
The verification we actually ship
Everything above collapses into a short list. RFC 8725, published as BCP 225 in February 2020, is the formal version and worth the twenty minutes.
- Name the accepted algorithms in the verify call. Never read
algfrom the token to decide anything. - Select the key by algorithm and issuer, never by a header field. If
kidis used, exact-match it against known ids. - Never fetch a URL taken from a token header unless the host is on an allowlist.
- 32 random bytes for HS256, a different value per environment, out of a CSPRNG.
- Validate
exp,aud,iss, andnbfif present. Keep clock skew to seconds, not minutes. - Keep access tokens short and put revocation somewhere: a refresh token, a version claim or a jti denylist.
- Put nothing confidential in the payload. It is base64url, readable by anyone holding the token, which is the whole point of base64 is not encryption.
- Reject tokens with no
typor an unexpected one when you can, and log verification failures with a reason. A sudden run of algorithm mismatches is someone probing.
None of this is expensive. Four verify options and one key-lookup rule cover the five mistakes, and they are the same five that appear in report after report.
JWT verification questions
How do I revoke a JWT before it expires?
You cannot, not with the token alone: a signed JWT stays valid until its exp passes, because verification is offline by design. The three practical answers are short access tokens (5 to 15 minutes) with a refresh token you can revoke in the database, a denylist of jti values kept until each token expires, or a per-user token version claim that you compare against a counter in the user record and bump on logout or password change. The version counter is the cheapest of the three: one integer per user, one comparison per request, and it invalidates every token for that user at once.
What is the algorithm confusion attack on JWT?
The attacker takes a token your server issued with RS256, changes the header to HS256, and signs it with your RSA public key used as the HMAC secret. If the verification code picks the algorithm from the token header instead of from its own configuration, it will happily verify the forgery, because the public key is public. The fix is to pass an explicit algorithm allowlist to the verify call (jwt.verify(token, key, { algorithms: ["RS256"] })) and to never derive the algorithm from the token being checked.
Should I validate the aud and iss claims?
Yes, and most codebases skip both. Without an aud check, a token minted for your analytics service is a valid login token for your billing service, since both verify against the same key. Without an iss check, any issuer whose key your JWKS endpoint list happens to include can mint tokens for you. RFC 8725 lists both as required steps, and every mainstream library accepts them as verify options, so it is one line each rather than new code.
How long should an HS256 secret be?
At least 256 bits of real randomness, which is 32 bytes from a CSPRNG, and never a word, a project name or a value copied from a tutorial. RFC 7518 requires a key at least as long as the hash output for HMAC-SHA256, and the practical reason is hashcat mode 16500: it grinds JWT candidates against a wordlist at billions of guesses per second on a single modern GPU, so a memorable secret is a few minutes of work.
Is it safe to use JWTs for user sessions?
It works, but it is often the wrong trade. JWTs pay for offline verification with the inability to revoke, and a session cookie backed by a store gives you instant logout, server-side expiry and smaller requests. The trade pays off when several independent services must verify a token without calling an auth service, which is exactly the case JWT was designed for. For a single monolith with one database, a session table is simpler and safer.
What does the jti claim do?
jti is a unique identifier for the token, defined in RFC 7519, and its practical use is replay protection and revocation. Store the jti of tokens you have accepted (or revoked) with their exp as the expiry, and you can reject a reused or withdrawn token without keeping the token itself. It only helps if you actually write and check it; issuing a jti nobody reads is decoration.
Can an attacker modify the payload of a JWT?
They can change any byte they like, and the signature check is what makes the change useless. The payload is base64url, not encryption, so it is readable and editable by anyone holding the token; the server rejects the modified token because the recomputed signature no longer matches. That protection disappears the moment verification is skipped, the algorithm is read from the token, or the secret is guessable, which is what the mistakes in this article have in common.
Should the JWT be checked in an API gateway or in the service?
Both, if the services are reachable at all without going through the gateway. Gateway-only verification turns any SSRF or misrouted internal call into an authentication bypass, which is why service meshes verify again at the sidecar. If the network genuinely prevents direct access, gateway verification plus a signed internal header is fine, and the gateway should strip any inbound copy of that header so a client cannot forge it.