October 31, 2026
The JWT Bug I Almost Walked Past : And Why Most Developers Still Get This Wrong
A messy, honest look at JWT attacks β alg confusion, weak secrets, key leakage, and the one check developers keep skipping

By Monika
5 min read
A few months into bug hunting, I found a login token that looked like three random blobs of text separated by dots. I almost ignored it. It looked like noise β the kind of thing you scroll past because it's "just an auth header." That blob was a JWT, and inside it, base64-decoded, was a JSON object that straight-up told me the user's role.
That's the thing about JWTs. They look like gibberish, but they're not encrypted β they're just encoded. Anyone who intercepts one can read it. The security of the whole system rests entirely on whether the signature actually gets checked properly on the way back in. A shocking number of implementations get this wrong, and once you learn where to look, you start seeing the same handful of mistakes everywhere.
First, what you're actually looking at
A JWT has three parts, separated by dots: header.payload.signature. The header and payload are just base64-encoded JSON β decode them and you can read them instantly:
echo "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" | base64 -d
# {"alg":"HS256","typ":"JWT"}echo "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" | base64 -d
# {"alg":"HS256","typ":"JWT"}The payload is where the interesting stuff usually lives β user_id, role, email, sometimes far more than it should contain. The signature is the only part that's supposed to stop you from editing any of that and having the server accept your changes anyway. Everything in this article is really about one question: can I get past the signature check somehow?
The "none" algorithm trick β old, but still alive
JWTs support an alg field in the header that tells the server which algorithm to use to verify the signature. One of the valid values, per spec, is literally none β meaning "don't verify anything." It exists for legitimate but rare use cases, and some JWT libraries historically honored it even when they shouldn't have.
Try this: take a valid token, change the header's alg to none, modify the payload however you like (say, "role":"admin"), and send the token with an empty signature:
eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJ1c2VyX2lkIjoxMjM0LCJyb2xlIjoiYWRtaW4ifQ.eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJ1c2VyX2lkIjoxMjM0LCJyb2xlIjoiYWRtaW4ifQ.Notice the trailing dot with nothing after it. On a library that still honors none, this works completely β no signature required at all. It's an old bug at this point, most mainstream libraries patched it years ago, but "old" and "gone" aren't the same thing, especially on internal tools, legacy codebases, or smaller custom JWT implementations that weren't kept up to date.
alg confusion β RS256 to HS256, a classic that still lands
This one's more interesting, and it still works surprisingly often. Here's the setup: RS256 is asymmetric β the server signs tokens with a private key and verifies them with a matching public key. HS256 is symmetric β one single secret both signs and verifies.
The bug happens when a server is configured to accept RS256 tokens, but its JWT verification library doesn't strictly enforce which algorithm is used β it just checks "does this signature match, using whatever algorithm the header claims?" If you can get your hands on the server's RSA public key (which, by design, isn't secret β it's often published at a .well-known/jwks.json endpoint, or embedded in the app itself), you can:
- Craft a new token, forging whatever payload you want
- Set the header's
algtoHS256 - Sign it using the public key as the HMAC secret
import jwt
public_key = open('server_public_key.pem').read()
forged_token = jwt.encode(
{"user_id": 1234, "role": "admin"},
public_key,
algorithm="HS256"
)import jwt
public_key = open('server_public_key.pem').read()
forged_token = jwt.encode(
{"user_id": 1234, "role": "admin"},
public_key,
algorithm="HS256"
)If the server's verification code does something like "look up the key for this algorithm, verify the signature" without pinning the expected algorithm ahead of time, it ends up checking your HMAC signature against the same public key string β and since you generated it with that exact string, it matches. You've just forged a trusted, admin-level token with nothing but publicly available information.
Finding the public key is usually the easy part:
https://target.com/.well-known/jwks.json
https://target.com/.well-known/openid-configurationhttps://target.com/.well-known/jwks.json
https://target.com/.well-known/openid-configurationWeak or guessable secrets β brute-force it
If the app uses HS256 (or any symmetric algorithm) legitimately, the secret used to sign tokens is, well, a secret. Except when it isn't. Developers sometimes use something dumb and guessable β the company name, "secret", "changeme", a default value copy-pasted from a tutorial and never rotated.
hashcat with a wordlist handles this well:
hashcat -a 0 -m 16500 jwt_token.txt wordlist.txthashcat -a 0 -m 16500 jwt_token.txt wordlist.txtThere's also a purpose-built wordlist for exactly this situation, floating around most JWT-focused toolkits β a few hundred of the most common weak secrets people actually use. If the secret cracks, you can now sign any token you want, forever, until it's rotated.
kid header injection β a quieter one
Some JWT implementations include a kid (Key ID) field in the header, telling the server which key to use for verification when multiple keys are in rotation. The server is supposed to look this up against a fixed, trusted set of keys. Some implementations instead do something careless β they take the kid value and use it to build a file path, or a database query, or worse, let it point somewhere the attacker controls:
{"alg": "HS256", "typ": "JWT", "kid": "../../../../dev/null"}{"alg": "HS256", "typ": "JWT", "kid": "../../../../dev/null"}On some implementations, pointing kid at /dev/null means the "key" the server reads back is an empty string β which you can then use as the HMAC secret for your forged signature. Other variations target SQL injection through the kid value if it's used in a database lookup, or SSRF if it's fetched from a URL.
Just⦠check the payload for things that shouldn't be there
This one isn't clever, but it catches real bugs constantly. Decode every JWT you come across and actually read what's inside:
echo "<payload_part>" | base64 -d | jqecho "<payload_part>" | base64 -d | jqI've seen tokens with internal server paths, database table names, full email addresses, and once, embarrassingly, what looked like a hashed password fragment that had no business being in a client-readable token at all. None of this requires forging anything β it's plain information disclosure, sitting there the moment you decode it.
None of this works if expiration and audience checks are solid β so test those too
Even a properly signed, un-forgeable token can misbehave if the server doesn't check exp (expiration) or aud (audience) properly. Grab an expired token and replay it:
GET /api/profile HTTP/1.1
Authorization: Bearer <token where exp has already passed>GET /api/profile HTTP/1.1
Authorization: Bearer <token where exp has already passed>If it still works, the server isn't checking expiration at all β a smaller bug on its own, but worth noting, especially combined with a leaked or logged token from somewhere else in the app (server logs, browser history, a referrer leak).
Reporting this stuff
Severity on JWT bugs swings wildly depending on what you can actually do with a forged token, so don't just report "I set alg to none." Show the before-and-after: here's a normal user's token, here's the forged admin token, here's the authenticated request using it that returns admin-only data. That contrast is what makes a triager take it seriously instead of asking for more detail.
Honestly, JWT bugs are one of the few bug classes where understanding the format matters more than running a tool. Once you can read a token in your head β decode it, picture what the server's doing with that alg field β you start noticing these issues on targets you've already tested twice before and somehow missed.
If this helped, the coffee link's below β and if you want the rest of what I've covered (IDOR, SSRF, GraphQL, recon automation, and more), it's all on my profile.
Monika Hi, I'm Monika β¨I break down real-world bug bounty reports into simple, practical write-ups.If my articles help youβ¦