6 JWT Validation Vulnerabilities and How to Fix Each One
JWT is one of those technologies that appears simple on the surface and punishes you for believing that. A header, a payload, a signature, how complicated can it be? That simplicity is exactly what makes JWT vulnerabilities so persistent.
Complicated enough that the jsonwebtoken Node.js library has shipped multiple critical CVEs, a single missing parameter in your verification call can turn a signed token into a forged one. Also, I have personally seen every one of the bugs below in real codebases.This blog covers six implementation-level vulnerabilities, each with working exploit code and an exact fix. If your JWT code doesn’t have test coverage for all six of these, read on.
How a JWT Is Structured
A JWT is three base64url-encoded segments: header, payload, signature. The header declares the token type and signing algorithm. The payload carries the claims. The signature is a cryptographic proof that the first two segments weren’t tampered with.
# Decoded JWT structure
Header: { "alg": "HS256", "typ": "JWT" }
Payload: { "sub": "user_123", "role": "admin", "exp": 1719619200 }
Signature: HMACSHA256(base64url(header) + "." + base64url(payload), secret)
Why "Signed" Doesn't Mean "Safe”
Here's the detail most jwt attacks exploit: the alg field that tells your server how to verify the signature is written by whoever created the token. If your verification logic reads that field and trusts it, the attacker is effectively choosing your validation method for you.
Every vulnerability below comes back to this same root cause: trusting a value the token controls instead of a value your server controls.
The Most Common JWT Attacks and Vulnerabilities
These are the six JWT vulnerabilities I have seen most often in production codebases, each with the exploit pattern and the fix.
1. The alg:none Signature Bypass
The JWT spec includes a none algorithm for unsecured tokens. Libraries that honor it will accept a token with an empty signature as fully valid. An attacker sets alg to none, fills in whatever claims they want, and submits the token with nothing after the final dot.
Fix: Never let the token dictate the algorithm. Pin the accepted algorithm(s) on the server:
Exploit — Python
import base64, json
def forge_token(claims: dict) -> str:
header = base64.urlsafe_b64encode(
json.dumps({"alg": "none", "typ": "JWT"}).encode()
).rstrip(b'=').decode()
payload = base64.urlsafe_b64encode(
json.dumps(claims).encode()
).rstrip(b'=').decode()
return f"{header}.{payload}." # empty signature segment
# Forge an admin token
token = forge_token({"sub": "user_99", "role": "admin", "exp": 9999999999})
# Vulnerable servers accept this as a valid, signed token
This is a textbook jwt signature bypass CVE-2018-1000531 (node-jws) and CVE-2016-10555 (auth0/node-jsonwebtoken) were both exploitable this way.
2. Algorithm Confusion: RS256 vs. HS256 Key Substitution
This is one of the more damaging jwt authentication bypass techniques because it turns a public key into a forging tool. If your verifier accepts both HS256 and RS256, an attacker can download your public key (it's meant to be public), sign a forged token with HS256 using that public key as the HMAC secret, and your server will validate it because mathematically, the HMAC matches.
Exploit — Python
import jwt
# Attacker downloads the server's public key (it's public — that's the point)
public_key = open('server_public.pem').read()
# Forge a token signed with the public key using HMAC
forged = jwt.encode(
{"sub": "user_1", "role": "admin", "exp": 9999999999},
public_key,
algorithm="HS256"
)
# Vulnerable server code:
# jwt.decode(token, public_key, algorithms=['HS256', 'RS256'])
#
# The server verifies: is HMAC(header.payload, public_key) == signature?
# Yes. The forged token passes verification.
Fix: Only one algorithm, one type of key with no exception save it:
Fix — Python (PyJWT)
# Wrong — accepting multiple algorithms opens the door to confusion attacks
payload = jwt.decode(token, key, algorithms=['HS256', 'RS256']) # ❌
# Correct — pin to exactly one algorithm
payload = jwt.decode(token, public_key, algorithms=['RS256']) # ✔
# If you're on HS256, the same rule applies — don't mix:
payload = jwt.decode(token, secret, algorithms=['HS256']) # ✔
If you are providing the services that support the algorithm migration. Fix it with the key and the transition window. There is no need to accept both algorithms at the same time.
3. Weak or Crackable HMAC Secrets
HS256 is only as strong as the secret behind it. If that secret is short, predictable, or reused from somewhere else, an attacker with a single valid JWT can crack it offline no server interaction required, no rate limiting, no lockouts.
Hashcat has a dedicated JWT mode (16500). Point it at a captured token and a wordlist, and it will recover weak secrets at millions of attempts per second.
Exploit — hashcat + Python
# Step 1: capture any valid JWT from the application
# (via HTTP logs, browser devtools, a public API response, anything)
# Step 2: run hashcat offline
hashcat -a 0 -m 16500 captured.jwt /usr/share/wordlists/rockyou.txt
# Step 3: with the recovered secret, forge any token you want
import jwt
forged = jwt.encode(
{"sub": "admin_user", "role": "superadmin"},
recovered_secret,
algorithm="HS256"
)
Fix: Generate secrets cryptographically (32+ bytes, e.g. secrets.token_hex(32)), store them in a vault, and rotate them. Better yet, move to RS256 or ES256 so there's no shared secret to crack at all.
4. Missing Expiry Validation
The exp claim is optional in the spec. If you don’t include it when issuing tokens, or you don’t check it when verifying them, a stolen token is valid indefinitely.
This turns a temporary credential into a permanent one. An attacker who intercepts a JWT via XSS, a logging leak, or a compromised third-party service now has permanent access to the account it was issued for.
Exploit — Python
import jwt
# Scenario 1: token issued without an expiry
no_expiry_token = jwt.encode(
{"sub": "user_1", "role": "admin"}, # no 'exp' field
secret, algorithm="HS256"
)
# This token works forever unless explicitly revoked
# Scenario 2: expiry exists but verification is disabled
payload = jwt.decode(
token, secret, algorithms=["HS256"],
options={"verify_exp": False} # ❌ seen in real codebases
)
# Expired tokens are accepted indefinitely
Fix: Always set exp when issuing tokens. Never disable expiry verification. PyJWT checks exp by default don’t override it.
Fix — Python (PyJWT)
import jwt, time
# Correct token issuance — always include exp
def issue_token(user_id: str) -> str:
return jwt.encode(
{
"sub": user_id,
"iat": int(time.time()), # issued at
"exp": int(time.time()) + 3600, # expires in 1 hour
},
SECRET,
algorithm="HS256"
)
# Correct verification — PyJWT checks exp by default, don't touch it
payload = jwt.decode(token, SECRET, algorithms=["HS256"]) # ✔
Access tokens usually expire in 15–60 minutes. It is important to refresh the tokens in 7–30 days with rotation. password reset tokens in 15 minutes with single-use invalidation. The shorter the token’s privilege level, the shorter its lifetime should be.
5. Missing Audience/Issuer Checks
JWTs carry two claims specifically designed to scope their usage: iss (issuer) identifies who created the token, and aud (audience) identifies who it was created for. If your verification code ignores these, you’re accepting tokens that were never meant for your service.
The Exploit - Python
# Setup: User Service and Payment Service share the same JWT secret
# Token issued by User Service for a standard user
user_token = jwt.encode(
{"sub": "user_1", "aud": "user-service", "role": "read"},
SHARED_SECRET, algorithm="HS256"
)
# Payment Service verifies WITHOUT checking aud:
payload = jwt.decode(
user_token, SHARED_SECRET, algorithms=["HS256"] # ❌ no audience check
)
# Token is accepted. 'role': 'read' is now interpreted in the Payment Service's context.
# Depending on the role structure, this may grant unintended access.
Fix: Pass audience and issuer into every decode call so mismatched tokens are rejected automatically.
6. Unverified kid Header Injection
The most sophisticated bug on this list and most difficult to spot in a code review. Some implementations read the kid (key ID) header to look up the correct verification key before checking the signature. At that point, the attacker controls kid, which can lead to path traversal, SQL injection in the key lookup, or the attacker pointing your server at a key they control.
Exploit — Python
import jwt, json, base64
def vulnerable_verify(token: str, key_store: dict):
# Step 1: read kid from header — BEFORE verifying anything
header_b64 = token.split('.')[0] + '=='
header = json.loads(base64.urlsafe_b64decode(header_b64))
kid = header.get('kid') # attacker controls this
# Step 2: use attacker-controlled kid to look up the key
key = key_store.get(kid) # what if kid is '../../../etc/passwd'?
# or an SQL injection vector?
# or a key the attacker registered?
# Step 3: verify with the attacker-influenced key
return jwt.decode(token, key, algorithms=['RS256'])
# Attack: forge a token with a kid pointing to an attacker-controlled key
# Sign it with the corresponding private key
# The server looks up the attacker's public key, and verification passes
Fix: Use a JWKS client that validates token structure before touching header claims, rather than a hand-rolled kid lookup.
Fix — Python (PyJWT + PyJWKClient)
from jwt import PyJWKClient
import jwt
# The JWKS client fetches keys from your auth server's well-known endpoint
# and handles kid-based lookup safely, after structural validation
jwks_client = PyJWKClient('https://auth.myapp.com/.well-known/jwks.json')
def verify_token(token: str) -> dict:
# get_signing_key_from_jwt validates structure before using any header claims
signing_key = jwks_client.get_signing_key_from_jwt(token)
return jwt.decode(
token,
signing_key.key,
algorithms=['RS256'],
audience='my-api',
issuer='https://auth.myapp.com'
)
Why JWT Signature Not Verified
"JWT signature not verified" is one of the most common findings in security audits, and it maps directly to CWE-347 (Improper Verification of Cryptographic Signature). The pattern is almost the same every time: a developer uses a function called decode(), expects it to return a readable payload, and assumes that the token is validated.
It wasn't. Decoding and verifying are two different operations, and conflating them is how unverified tokens end up trusted in production.
python
# Vulnerable — reads the payload, checks nothing
payload = jwt.decode(token, options={"verify_signature": False})
# Correct — actually verifies the signature, algorithm, and expiry
payload = jwt.decode(token, SECRET, algorithms=["HS256"])
If your audit tooling or a pen test flags "signature not verified," check every place in your codebase where a JWT is parsed, not just your primary auth middleware.
jwt.decode() vs jwt.verify()
Confusing jwt.decode vs jwt.verify is the single most common way teams end up with unverified tokens in production, because the two functions can look interchangeable at a glance.
What decode() Actually Does
decode() (in most libraries) parses the base64 segments and returns the header and payload as readable objects. In many libraries it does not check the signature unless you explicitly tell it to.
What verify() Does
verify() or decode() called with the correct options checks the signature against your key, confirms the algorithm matches what you expect, and validates time-based claims like exp and nbf.
Library-by-Library Gotchas
The naming isn't consistent across ecosystems, which is exactly why this mistake keeps recurring. The rule that holds across all of them: if you haven't passed a key and an explicit algorithm list, you haven't verified anything.
JWT Security Best Practices for 2026
A practical jwt security best practices checklist, updated for the threat patterns we're seeing in 2026 as JWT-based auth spreads further into machine-to-machine and AI-agent authentication flows.
- Pin algorithms server-side. Never derive the verification algorithm from the token's own header.
- Prefer RS256/ES256 over shared HMAC secrets, especially across service boundaries.
- Set short, role-appropriate token lifetimes — the higher the privilege, the shorter the life.
- Scope every token with aud and iss, and check both on every decode.
- Use a JWKS client for key rotation instead of manual kid lookups.
- Log and monitor token issuance and verification failures, and build in a revocation path (deny-lists or short-lived tokens plus refresh rotation) since JWTs are stateless by design and can't be revoked outright.
This is the backbone of jwt security 2026 guidance: nothing here is new cryptography, it's discipline about which side of the trust boundary makes each decision.
OWASP Guidance on JWT Security
The OWASP JWT Cheat Sheet is the closest thing this space has to an industry standard, and its core recommendations align closely with the fixes above: validate algorithms explicitly, avoid the none algorithm entirely, enforce expiration, and treat every header claim as untrusted until the signature check passes.
For teams building or auditing auth systems, it's worth reviewing directly at OWASP's Cheat Sheet Series rather than relying on secondhand summaries, since guidance is updated as new attack patterns emerge.
Final Thoughts
Looking at these vulnerabilities together, the pattern is consistent: each one is a case of trusting the token.
The alg:none bypass trusts the token to tell you how to verify it. Algorithm confusion trusts the token to pick the key type. Weak secrets trust that nobody will brute-force offline.
Missing exp trusts the token to self-expire. Missing audience checks trust the token to behave correctly across service boundaries. The unverified kid lookup trusts the token to honestly identify its own signing key.
JWT security is fundamentally about distrusting the input you’re verifying. The signature is the one thing you can trust but only after you’ve verified it against keys and algorithms that you control, not ones the token specified.
If there’s one practice change to take from this post, it’s this: treat every field in a JWT header and payload as attacker-controlled until the signature is validated.



.png)







