JWT alg:none Is Still in Production in 2026. Here's How
JWT alg:none became a meme in 2015. In 2026 it still shows up in production. A breakdown of the 4 modern variants and how to test and fix them.
Security researcher and founder of No Vuln

In 2015, JWT alg: none became a meme — the vulnerability so basic nobody could possibly still have it in production. In 2026, I still find it in production. How is that possible?
Short answer: the original bug “died” (modern libraries reject it by default), but 4 modern variants keep showing up in pentests with uncomfortable regularity. This post is the technical guide to each variant, how to detect it in 30 seconds, and how to fix it.
The original bug (a quick recap)
JWT lets you specify the signing algorithm in the header. In 2015, people discovered that many libraries accepted alg: none — that is, a JWT with no signature. The attacker edited the payload, stripped the signature, and the server accepted it:
- Header:
{"alg":"none","typ":"JWT"} - Payload:
{"sub":"admin","role":"admin"} - Signature: (empty)
The server processed it as a valid admin token. A global catastrophe.
Today, modern libraries (jsonwebtoken v9+, PyJWT 2+, JJWT 0.12+) reject alg: none by default. Bug dead. Except it isn't dead. It changed shape.
Variant 1: Algorithm Confusion (HS256 vs RS256)
The bug: the server is configured to validate the JWT with RS256 (asymmetric signature), but the verification function accepts HS256 (symmetric signature). The attacker uses the server's RSA PUBLIC key as the HMAC secret.
Why it works:
- RS256 validates with the
public_key - HS256 validates with the
shared_secret - The RSA public key is available at a public endpoint (
/.well-known/jwks.jsonor similar) - The
jwt.verify(token, public_key)function, without specifying an algorithm, accepts both - The attacker forges a JWT with
alg: HS256using the public key as the HMAC secret - The server validates with HS256 + the public key = success
How to detect it in a pentest:
- Capture a legitimate JWT
- Decode the header (without validating) and identify
alg: RS256 - Try forging with HS256 using the public key as the secret
- If the server accepts it → vulnerability confirmed
How to fix it:
- ❌ Vulnerable:
jwt.decode(token, public_key) - ✅ Correct:
jwt.decode(token, public_key, algorithms=['RS256'])with an explicit whitelist
Variant 2: JKU / X5U SSRF
The bug: optional JWT header parameters (jku, x5u) let you point to an external URL from which to fetch the verification key. A server that trusts it blindly makes a request to a URL the attacker controls.
Exploitation: the attacker adds "jku": "https://attacker.com/jwks.json" to the JWT header. The server sends a GET to the attacker's URL, receives the attacker's public key, validates the JWT with it → the attacker has forged a valid token.
Bonus: beyond an auth bypass, this becomes an SSRF chain. Adding "jku": "http://169.254.169.254/latest/meta-data/iam/security-credentials/" makes the server request internal AWS metadata. The response goes to the JWKS parser (it fails to parse, but the request was made). Cloud metadata exfiltrated.
How to detect it:
- Add
jkuto a legitimate JWT header pointing tohttps://burpcollaborator.net/ - Send the request with the modified JWT
- If Burp Collaborator receives a ping → vulnerability confirmed
How to fix it:
- An explicit allowlist of permitted JKU URLs
- Better: ignore the
jku/x5uparameters entirely if you don't use them (configure the library to disable them)
Variant 3: kid Path Traversal
The bug: the kid (Key ID) parameter is used to select which key validates the token. A server that uses kid as a path to a file on the filesystem accepts path traversal.
Classic exploitation: the attacker sends "kid": "../../../../tmp/known_content". If the attacker can write to /tmp/known_content (via a file upload, log poisoning, etc.), they can make the server “read” that known key and validate a JWT forged with it.
The more common variant in 2026: kid accepts SQL injection (the server looks up the key in the DB). The attacker sends "kid": "1' UNION SELECT 'KNOWN_KEY' --".
How to detect it:
- Capture a JWT, change
kidto../etc/passwd - If the validation error changes (file not found, a specific parse error) → vulnerable
- Test the
kidfor SQL injection (' OR 1=1 --)
How to fix it: kid must be an opaque ID, validated against a fixed allowlist. NEVER used as a filesystem path or a SQL string.
Variant 4: Token expiration ignored
The bug: simpler and more common than it sounds. The server doesn't validate the exp claim. Some libraries don't validate it by default — you have to pass an explicit flag.
A token stolen in 2023 is still valid in 2026. An eternal session. It's the kind of bug that survives multiple pentests because nobody thinks to test it (everyone assumes it's validated).
How to detect it:
- Change
expin the payload to a past date - Re-sign it (if you have the test secret) or use a real expired token
- If the server accepts it → vulnerable
How to fix it: always pass options={'require': ['exp'], 'verify_exp': True} or the library's equivalent.
Why these variants still exist in 2026
Three practical reasons:
1. Old libraries in legacy production systems
ERP systems, government systems, legacy banking systems use libraries from 2017–2020 that still have the bugs. A major-version migration is work nobody prioritizes.
2. Suboptimal default configurations
Developers copy the README example: jwt.decode(token, key). Without reading that they need to pass algorithms=['RS256']. A CVE waiting to happen in production.
3. Code review with no focus on auth
Auth code rarely goes through specialized code review. The bug lands in a “minor” feature PR and never gets noticed. Statistically, any team without dedicated auth review has at least 1 of these variants in production.
How to test it yourself in 30 minutes
Use jwt.io or a similar tool:
- Paste your JWT into the decoder
- Check the
algin the header - Change it to
noneand re-paste — if the server accepts it (testing against a protected endpoint), it's vulnerable - If
alg: RS256— try HS256 using the public key as the secret (variant 1) - Add
jkuto the header pointing to a server you control — if the server makes a request, variant 2 is confirmed - Change
expto a past date — if it's accepted, variant 4
5 tests. 30 minutes. If any of them passes — you have an open CVE in production.
The complete secure configuration
The correct pattern that every JWT implementation should follow:
- Algorithms whitelist:
algorithms=['RS256']or equivalent, with no fallback - Required claims:
options={'require': ['exp', 'iat']} - Active validation:
verify_exp, verify_iat, verify_nbf, verify_aud, verify_issallTrue - Expected audience:
audience='api.target.com' - Expected issuer:
issuer='https://auth.target.com' - No
jkuaccepted. Nokidthat accepts a path. No algorithm fallback. No optional expiration.
The most important lesson
The 4 patterns above are not obscure bugs. Each one shows up in pentests regularly. The difference between a vulnerable team and a secure team isn't talent — it's process:
- Code review focused on auth
- Periodic pentesting that specifically validates these patterns
- Keeping libraries updated (PyJWT, jsonwebtoken always on the latest major)
- CI with automated JWT validation tests
If your application uses JWT (and almost every one does in 2026), it's worth 30 minutes testing the 4 vectors yourself before you discover them the hard way.
To talk about a pentest focused on auth and JWT, see the dedicated pages: API penetration testing and SaaS penetration testing. To understand other auth patterns that dominate ATO in 2026, see OAuth Account Takeover.
Next step
Want to apply this to your system?
No Vuln runs in-depth penetration tests with the same methodology described in this article. Request a proposal: mutual NDA within 24h, scope defined on a technical call.