JWT Security Deep Dive: Algorithm Confusion to Full Bypass
Five JWT failure modes account for the overwhelming majority of real-world authentication bypasses. Each one collapses onto the same root cause - the verifier let data inside the token decide how the token would be verified. Here is how each plays out, why they keep shipping, and what a correct verifier actually looks like.
JSON Web Tokens are not broken. Most implementations that use them are. The spec - RFC 7519, with its companion RFC 7515 for signing - describes a perfectly serviceable stateless authentication primitive. The trouble is that the spec assumed a level of caution from implementers that the industry never actually delivered. So bugs that should have stayed in 2015 keep showing up in fresh codebases in 2026.
What follows is a practitioner's tour of the five failure modes that, in our reviews of real CVEs and red-team engagements, account for roughly 90% of JWT bypasses worth talking about. Each one is conceptually distinct, but they all collapse onto the same root mistake: the verifier let data inside the token influence how the token would be verified.
alg:none
RFC 7519 lists none as a valid algorithm value. It exists for cases where token integrity is already guaranteed by some other means - for instance, when the token never leaves a single trusted process. With alg=none, the signature segment is empty, and the verifier is supposed to do no cryptographic work.
Read that again, because it's the whole bug: the decision about whether to verify the token is, by spec, made by reading a field inside the token. The token comes from the client. The client may be hostile.
header = { "alg": "none", "typ": "JWT" }
payload = { "sub": "u_4812", "role": "admin" }
token = b64url(header) + "." + b64url(payload) + "."The trailing dot is required by the spec - JWTs are three-segment strings, even when the third segment is empty. A vulnerable verifier reads alg=none, concludes there's nothing to check, parses the payload, and admits the user as administrator. It's the simplest attack in the book and still the one that finds bug bounties in 2026.
Algorithm confusion: HS256 vs RS256
The classic. RS256 uses asymmetric keys: the server holds an RSA private key, clients verify with the matching public key. Public keys, by definition, are not secret. They are routinely published at /.well-known/jwks.json or hardcoded into single-page-app bundles.
Now imagine a verifier that resolves the algorithm from the header rather than pinning it server-side:
key = load_public_key()
algo = token.header.alg
verify(token, key, algo)If the attacker switches the header to alg=HS256 and signs the token with the public key bytes treated as an HMAC secret, the verifier dutifully runs HMAC-SHA256 over the message using those exact bytes and finds a match. The 'public' key has become a 'shared' key - purely because the algorithm changed underneath it. There's no exotic crypto failing here; it's a contract violation. The verifier was supposed to know in advance which algorithm it would use.
Weak HMAC secrets
JWT libraries often default to HS256 with a shared secret. If that secret is short, dictionary-derived, or framework-default, an attacker can crack it offline once they have a single valid token. hashcat handles this with mode 16500 and an mdxfind-style dictionary; on commodity hardware, an 8-character lowercase secret falls in minutes.
$ hashcat -m 16500 -a 0 token.jwt rockyou.txt
# … 18 minutes later
MyShinyApp_2018 ← yes, that was the secretCracked secret means the attacker can issue arbitrary tokens for arbitrary users - including any privileged role the schema knows about. This is one of the few real-world cases where 'rotate the key and move on' is not enough; you also need to revoke every token issued during the exposure window, which is rarely possible cleanly with stateless JWTs.
kid header injection
Some tokens include a kid (Key ID) header to tell the verifier which key out of a set to use. When the server uses kid as input to a file path, a SQL query, or an HTTP fetch, you are back in 2002:
{ "alg": "HS256", "kid": "../../../../dev/null" }If the verifier reads the file at the kid path and uses its contents as the HMAC secret, /dev/null gives the attacker a known secret of zero length. The attacker signs the token with the empty string and it verifies cleanly. The same pattern has been reported with SQL injection in kid (SELECT key FROM keys WHERE id = '...'), SSRF in kid (https://attacker/), and prototype pollution in kid (__proto__).
jku and x5u abuse
The jku and x5u headers are even more direct: they tell the verifier where to fetch the verification key. If those URLs are not strictly allow-listed, an attacker hosts a public key on their own infrastructure, signs the token with the matching private key, and watches the verifier fetch the key it has just been told to use.
This bug class is responsible for several enterprise-grade breaches because the relevant config typically lives in middleware that hasn't been reviewed since the SSO integration was first stood up. The defence is dead simple - allow-list the jku/x5u host - but the bug stays alive because nobody opens that file unless they're forced to.
What a correct verifier looks like
The single largest gain you can make in JWT security has nothing to do with crypto at all. It's a posture change at the verifier:
- Pin the algorithm. The verifier knows up front which algorithm and which key it will use. The header's alg field is consulted only to check that it matches the pin - anything else is a hard reject.
- Pin the key. Even with multiple kids in play, the set of valid kids is finite and known. Reject anything outside the set before any lookup runs.
- Use verify(), never decode(). They are different functions. decode() will happily hand you the claims for an unsigned, tampered, or expired token - and many incident reports start with a developer who used the wrong one in a hot path.
- Always validate exp, nbf, iss, and aud. Skipping these is a separate class of bug, but it converts a stolen valid token into a permanent skeleton key.
- Treat kid, jku, x5u, and embedded jwk parameters as untrusted strings. If you must use them, allow-list inputs, sanitise paths, and constrain hosts.
Closing
JWTs do not have a crypto problem. They have an interface problem. The spec hands the implementer a series of choices that look benign in isolation, and the safe configuration is the one nobody is forced to make. Every bug in this post collapses onto the same fix - pin the algorithm and the key on the server, and never let the token tell you how to verify itself. Everything else is implementation detail.