A JWT is just a signed claim about who you are. If the verifier trusts that signature blindly, your entire login system rests on one fragile assumption — and attackers know exactly where to push.

The anatomy
A token is three base64url chunks: header.payload.sig. The header names the algorithm, the payload carries claims like sub, exp, and aud, and the signature proves the server minted it. Everything before that signature is readable by anyone — base64 is not encryption. The only thing between a user and forged admin claims is whether the server verifies that last segment correctly.
Decode a real token and you will see something like this:
// header
{ "alg": "HS256", "typ": "JWT" }
// payload
{ "sub": "1024", "role": "user", "exp": 1735689600, "iss": "auth.myapp.com" }Paste any JWT into a decoder and both halves render instantly, no key required. That is by design — the payload is meant to be transparent. The mistake engineers make is treating it as tamper-proof. It is not tamper-proof; it is tamper-evident, and only if you check the evidence.
The big three bugs
These are the failures that show up again and again in real audits:
alg=none: the spec allows an "unsecured" token with no signature. A naive library sees"alg": "none", accepts the token as valid, and the attacker simply deletes the signature and rewrites the payload. Fliprolefromusertoadmin, drop the third segment, and a vulnerable server waves it through.- RS256 to HS256 confusion: the server expects an asymmetric RS256 token verified with a public key. An attacker flips the header to HS256 and signs with that public key as the HMAC secret. Because the public key is, by definition, public, the attacker has everything needed to forge a "valid" token. A library that picks the algorithm from the token gets fooled.
- Missing claim checks: a valid signature is not enough. If you never check
exp,aud, oriss, you happily accept expired tokens — or tokens minted for a different service that happens to share your signing key.
Walking the alg-confusion attack
This one deserves a slower look because it is the most misunderstood. In RS256, the server holds a private key and signs; anyone with the public key can verify but not sign. That asymmetry is the whole point.
Now watch the attack. The attacker takes your published public key — often literally downloadable from a /.well-known/jwks.json endpoint — and crafts a token with "alg": "HS256". HS256 is symmetric: the same key signs and verifies. A vulnerable verifier reads alg from the attacker's header, sees HS256, and reaches for "the key" — which for this server is the RSA public key string. It then runs HMAC over the token using that public string as the secret. The attacker did the exact same computation. The signatures match. Forgery complete.
The root of the bug is a single line: letting the token's header decide which verification path to run. The token is the thing you do not trust yet, and you let it pick its own exam.
Weak secrets die offline
Even a correctly-pinned HS256 setup falls if the secret is guessable. HMAC verification is fast, which is a gift to attackers running an offline dictionary attack. They capture one valid token, then hash-and-compare millions of candidate secrets per second on a laptop — no network, no rate limit, no lockout.
# an attacker's whole workflow against a weak HS256 secret
hashcat -m 16500 captured.jwt rockyou.txtsecret, changeme, myapp2023, your company name — all fall in seconds. A real secret is 32+ bytes of cryptographic randomness, stored in a secrets manager, never in source control. If a token was ever signed with a weak secret, rotating to a strong one is the only fix; the old tokens are already forgeable.
Revocation: the problem nobody mentions in the tutorial
Here is the uncomfortable truth about stateless JWTs: you cannot un-issue one. A session cookie can be deleted server-side the instant a user logs out or an account is compromised. A signed JWT is valid until it expires, full stop — even if you fire the employee, even if the token leaked to an attacker five minutes ago.
There are three honest answers, and you pick based on your threat model:
- Short TTLs plus refresh tokens. Access tokens live 5–15 minutes; a longer-lived refresh token mints new ones. The blast radius of a leaked access token is now minutes, not days.
- A denylist. Keep revoked token IDs (
jti) in a fast store like Redis until their natural expiry. This reintroduces state — which is exactly what JWTs were supposed to avoid — so only do it where instant revocation genuinely matters. - A rotating key with versioning. Bump the signing key and every token signed with the old one dies at once. Blunt, but effective for a "log everyone out now" button.
Anyone who tells you stateless auth is free hasn't hit the revocation wall yet.
The fix
Defense here is boring on purpose. Pin the algorithm server-side — never let the incoming token choose it. Validate the claims (exp, aud, iss) on every request, not just the signature. Keep access tokens on short TTLs with rotating refresh tokens, and use a secret with real entropy.
jwt.verify(token, key, {
algorithms: ["RS256"], // pin it — don't trust the header
audience: "api.myapp.com",
issuer: "auth.myapp.com",
maxAge: "15m",
});A few more rules that catch the long tail of bugs:
- Store tokens carefully. In a browser,
localStorageis readable by any XSS payload; anHttpOnly,Secure,SameSitecookie keeps the token out of JavaScript's reach. - Keep secrets out of the payload. Anyone can read it — never put a password, a full session, or PII you would not print on a billboard.
- Prefer vetted libraries over hand-rolled verification. Every custom "just split on the dots and check" implementation reinvents
alg=none.
Why it keeps happening
The root cause is almost never the crypto — it is trusting attacker-controlled metadata. The token's header is data from a hostile source, yet libraries historically let it drive verification decisions. Treat every field before the verified signature as untrusted input, and most of these bug classes simply evaporate.
There is a deeper lesson for any authentication system, JWT or not: the security boundary is the moment you decide this request is who it claims to be. Everything that decision depends on — the algorithm, the key, the claims — has to be chosen by you, from trusted configuration, never read from the thing being authenticated.
The takeaway
- The header is attacker input — pin the algorithm, never read it from the token.
- A valid signature is step one, not the finish line — always validate
exp,aud, andiss. - Weak secrets fall offline — use 32+ bytes of real randomness and store it in a secrets manager.
- Stateless means unrevocable — plan short TTLs, a
jtidenylist, or key rotation before you need them. - Short TTLs and strong secrets shrink the blast radius when something does leak.
