JSON Web Tokens are the default bearer credential of the modern web. They sit in your Authorization header, in cookies, in the calls your services make to each other. And most of the time they work quietly, which is exactly the problem. A JWT that is signed with the secret secret looks identical to one signed with a 256 bit random key, right up until someone runs a wordlist against it and starts minting admin tokens.

I spend a lot of time looking at tokens, so let me walk through the three ways I actually try to forge one, why each works, and the single defensive habit that closes all three at once. I will use a small tool I built, jwt-auditor, to show each attack against a real token.

First, a thirty-second refresher. A JWT is three base64url pieces joined by dots: a header, a payload, and a signature. The header says which algorithm signed the token. The signature is computed over the header and payload together. Verification recomputes that signature and compares. Every attack below is an attack on one step of that process. And one fact matters more than any other: the payload is encoded, not encrypted. Anyone holding the token can read every claim in it.

Attack one: tell the server the token is unsigned

The header names the algorithm. One legal value in early implementations was none, meaning the token carries no signature at all. So the attack is almost insulting in its simplicity. Take a valid token, rewrite the header to {"alg":"none"}, change the payload to say "role":"admin", delete the signature, and send it. A verifier that trusts the header and honors none accepts it as authentic.

This is not a hypothetical from a textbook. In 2015 a wide range of JWT libraries were found to accept alg: none by default, tracked as CVE-2015-9235 for the popular Node jsonwebtoken library and echoed across many others. The reason it was so dangerous was the default behavior. A developer calling verify(token) with no extra arguments got the insecure path for free.

Attack two: guess the secret offline

HS256, HS384, and HS512 sign with HMAC, which uses a shared secret. The entire security of the token rests on that secret being unguessable. The problem is that when a human picks the secret by hand, they pick secret, or changeme, or the placeholder your-256-bit-secret from the jwt.io debugger that somehow ends up in production.

Here is the part people underestimate. Because the attacker already holds a valid token, they can guess the secret offline. There is no login form to rate limit them and no lockout to trip. They try a candidate, recompute the HMAC over the token’s own header and payload, and compare it to the signature that is already sitting in the token. A match means they found the key, and now they can sign anything they want.

$ jwt-auditor crack <token> --wordlist rockyou.txt Secret found: 'changeme' The token can now be forged. Rotate this key.
That is the whole attack. No server involved.

Attack three: turn the public key into the secret

This is the interesting one, and the one most teams have never thought about. RS256 signs with a private key and verifies with a public key. The public key is meant to be public. You might publish it at a well known URL. That is fine, by design.

Algorithm confusion breaks the assumption underneath that design. Suppose the server verifies with "whatever algorithm the token header says." The attacker changes the header from RS256 to HS256. Now the server runs HMAC verification instead of RSA. And what does it use as the HMAC secret? The only key it has: the RSA public key. Which is not secret. The attacker downloads that public key, signs a forged HS256 token using the public key bytes as the HMAC secret, and the server happily verifies the forgery.

There is a subtle detail here that trips up both attackers and defenders, and it is worth knowing. HMAC runs over exact bytes, so whether the stored public key has a trailing newline changes every byte of the output. A forgery signed with the PEM as stored will not verify against the PEM with the newline stripped. When I built jwt-auditor's confusion check I had it try the common byte variants of the key for exactly this reason, because in the real world servers disagree about that trailing newline.

$ jwt-auditor audit <token> --public-key server_pub.pem CRITICAL Token verifies with the public key as an HMAC secret public key PEM as stored (verified as HS256)
If you see that line against your own token and your real public key, your server is forgeable. This class of bug shows up again and again and lives under CWE-347, improper verification of a cryptographic signature. RS256 by itself is fine. The bug is the verifier trusting the header to choose the algorithm.

The one habit that closes all three

Look at what these attacks have in common. alg: none is the server trusting the header's algorithm. The confusion attack is the server trusting the header's algorithm. Even the weak secret attack is really about not treating the signing key as a real secret. The root cause is the same every time: trusting data inside the token to decide how to verify the token.

So the habit is this. Never let the token choose its algorithm. Decide server side which algorithms you accept, pin them to an explicit allowlist, and reject everything else including none.

```

The safe pattern

ALLOWED = {"RS256"}
claims = jwt.decode(token, public_key, algorithms=list(ALLOWED))
`` That one argument, an explicit algorithm allowlist, killsalg: none` and the RS to HS confusion in a single stroke. Add a long random secret from a real CSPRNG and you have closed the third door too. Then set a short expiry, and keep passwords and PII out of the payload, because remember, it is only encoded.

Check your own tokens

None of this is theoretical, so do not take my word for it. Grab a token from your own app, in a staging environment you are authorized to test, and run it through an audit.

$ jwt-auditor audit <token>
It will tell you if the algorithm is none, if the secret is guessable, whether the token verifies with a public key you supply, and whether it is quietly carrying data it should not.

The tool is open source and runs entirely offline. You can grab it here: github.com/mohelobeid/jwt-auditor

git clone https://github.com/mohelobeid/jwt-auditor cd jwt-auditor && uv sync uv run jwt-auditor audit <token>
Twenty seconds of checking is a lot cheaper than finding out the hard way. The attacks are old. The defense is a one line habit. The only real mistake is assuming the token verifies itself.