Cybersecurity Application Security

JWT Validation Mistakes That Let Attackers Forge Tokens

June 15, 2026 8 min read 2 views

You added JWT authentication, your tokens are signed, and your tests pass. Then a security researcher emails you a token they crafted in five minutes that your server accepted without question. This is not hypothetical — JWT libraries have a long history of insecure defaults, and the most dangerous bugs look completely fine in a code review.

This article maps out the validation mistakes that actually get exploited, explains the mechanics behind each one, and shows you concrete code changes to close them.

What You'll Learn

  • Why JWT signature verification can be bypassed without touching the secret key
  • How the alg:none trick and algorithm confusion attacks work
  • What makes a weak HMAC secret dangerous even with correct validation logic
  • Which claims you must validate beyond the signature
  • How JWKS endpoint handling introduces its own class of vulnerabilities

Prerequisites

You should be comfortable reading JSON and have a working understanding of how JWTs are structured (header, payload, signature). The code examples use Python, but the concepts apply to any language or framework.

How JWT Signature Verification Actually Works

A JWT is three base64url-encoded segments separated by dots: header.payload.signature. The header declares which algorithm was used. The signature covers the first two segments, so any change to the header or payload should invalidate it.

The core verification contract is this: the server takes the algorithm named in the header, applies it to header.payload using the appropriate key, and compares the result to the third segment. If they match, the token is authentic. If they don't, it's rejected.

The problem is that phrase "the algorithm named in the header." The attacker controls the header. If your library blindly trusts it, the attacker also controls how verification is performed.

The alg:none Attack — Disabling Signature Verification Entirely

The JWT spec defines none as a valid algorithm value, intended for situations where the token's integrity is guaranteed by an outer transport layer. In practice, some libraries honor it when they shouldn't.

An attacker can take any valid token, decode the payload, modify the claims (say, change "role": "user" to "role": "admin"), re-encode the header with "alg": "none", and drop the signature entirely. The resulting token looks like this:

eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiIxMjMiLCJyb2xlIjoiYWRtaW4ifQ.

Note the trailing dot with nothing after it — that's the empty signature. Vulnerable libraries see alg: none, skip the cryptographic check, and return the payload as trusted.

How to fix it

Always pass an explicit list of allowed algorithms to your verification call. Never derive the algorithm from the token itself.

import jwt

SECRET = "your-secret-here"
ALLOWED_ALGORITHMS = ["HS256"]  # whitelist — never include "none"

def verify_token(token: str) -> dict:
    try:
        payload = jwt.decode(
            token,
            SECRET,
            algorithms=ALLOWED_ALGORITHMS,  # explicit whitelist
        )
        return payload
    except jwt.InvalidTokenError as e:
        raise ValueError(f"Invalid token: {e}")

With PyJWT, omitting the algorithms argument used to default to accepting anything. Recent versions require it, but older pinned dependencies may not — always check your library version and its defaults.

Algorithm Confusion: Switching RS256 to HS256

This attack is subtler and catches developers who think they are safe because they use asymmetric signing. RS256 uses a private key to sign and a public key to verify. HS256 uses the same secret for both operations.

The attack works like this: your server publishes its RSA public key (common for JWKS endpoints). An attacker downloads that public key, crafts a token signed with HS256 using the public key as the HMAC secret, and sets the header to "alg": "HS256". If your verification code accepts whatever algorithm the header declares and uses the same key object for both RS256 and HS256, it will try to verify an HMAC signature using the public key bytes — and the attacker's token will pass.

# VULNERABLE: algorithm is read from token header
def verify_token_unsafe(token: str, public_key: str) -> dict:
    header = jwt.get_unverified_header(token)
    alg = header["alg"]  # attacker controls this
    return jwt.decode(token, public_key, algorithms=[alg])  # never do this

# SAFE: algorithm is hardcoded server-side
def verify_token_safe(token: str, public_key: str) -> dict:
    return jwt.decode(
        token,
        public_key,
        algorithms=["RS256"],  # fixed, not from token
    )

The rule is simple: the expected algorithm is a server-side configuration value, not something you read from the incoming token.

Weak HMAC Secrets and Offline Brute-Forcing

If you use HS256, the security of every token depends entirely on the strength of your secret. A valid signed token is all an attacker needs to attempt an offline dictionary attack — they don't need any server access after that.

Tools like hashcat and jwt-cracker can test millions of candidates per second against a stolen token. Short secrets, common words, or secrets that look like passwords all fall quickly. The HMAC operation is fast by design, which works against you here.

What a weak secret looks like in practice

# These are all dangerously weak HMAC secrets:
SECRET = "secret"
SECRET = "changeme"
SECRET = "jwt_secret_key"
SECRET = "mysupersecretkey123"

# A safe secret: 256 bits (32 bytes) of cryptographic randomness
import secrets
SECRET = secrets.token_hex(32)  # generate once, store in environment variable

Store the generated secret in an environment variable or a secrets manager — never hardcode it in source code. If you're choosing between algorithms, RS256 eliminates this attack surface entirely because you never distribute the signing key.

Skipping Claims Validation: exp, iss, and aud

Verifying the signature only proves the token was issued by someone who held the key. It says nothing about whether the token is still valid, was meant for your service, or came from the expected issuer. Plenty of implementations stop at signature verification and call it done.

Expiry (exp)

If you don't check exp, tokens are valid forever. An attacker who captures a token — from a log file, a compromised client, or a man-in-the-middle position — can use it indefinitely. Most libraries check exp by default when it is present, but not all do, and some have options to disable it.

# PyJWT checks exp by default. To explicitly ensure it:
payload = jwt.decode(
    token,
    SECRET,
    algorithms=["HS256"],
    options={"verify_exp": True},  # default True, but be explicit in critical paths
)

# Never do this unless you genuinely need a time-window test:
payload = jwt.decode(
    token,
    SECRET,
    algorithms=["HS256"],
    options={"verify_exp": False},  # disables expiry check — only for debugging
)

Issuer (iss) and Audience (aud)

In a microservices architecture, multiple services may accept tokens from the same identity provider. Without checking aud, a token issued for your billing service is valid at your admin service too. Without checking iss, a token from a different tenant's auth server is accepted.

payload = jwt.decode(
    token,
    PUBLIC_KEY,
    algorithms=["RS256"],
    audience="https://api.yourapp.com/billing",  # reject tokens for other services
    issuer="https://auth.yourapp.com",           # reject tokens from other issuers
)

These two lines cost nothing and eliminate a whole class of cross-service token replay attacks.

JWKS Endpoint Pitfalls

JSON Web Key Sets (JWKS) are the standard way to publish public keys for RS256 verification. Your service fetches the keys from the identity provider's endpoint and caches them. This flow has its own set of traps.

Key injection via the jku or x5u header

Some libraries support the optional jku header, which tells the verifier where to fetch the signing key. If your code honors this header from the incoming token, an attacker can point it at a server they control, return a key they own, sign a token with the matching private key, and your server will verify it as valid.

The fix: pin the JWKS URL in your server configuration. Never follow a URL from the token header itself.

Kid (key ID) injection

The kid header is used to select which key from a JWKS to use for verification. If your code passes the kid value directly into a database query or file path without sanitisation, it can become a vector for SQL injection or path traversal. Treat kid as untrusted input: validate it against a known set of allowed key IDs before using it.

ALLOWED_KEY_IDS = {"key-2024-01", "key-2024-02"}  # loaded from config

def get_key_for_token(token: str, jwks: dict) -> str:
    header = jwt.get_unverified_header(token)
    kid = header.get("kid")

    if kid not in ALLOWED_KEY_IDS:
        raise ValueError(f"Unrecognised key ID: {kid}")

    for key in jwks["keys"]:
        if key["kid"] == kid:
            return key

    raise ValueError("Key not found in JWKS")

Common Pitfalls at a Glance

Mistake Impact Fix
Accepting alg:none Complete signature bypass Whitelist algorithms server-side
Algorithm read from token header RS256→HS256 confusion attack Hardcode expected algorithm in config
Weak HMAC secret Offline brute-force of signing key Use 256-bit random secret or switch to RS256
Skipping exp check Stolen tokens valid indefinitely Enable expiry verification (usually default)
No aud / iss check Cross-service token replay Pass audience and issuer to decode call
Trusting jku header URL Attacker-controlled key injection Pin JWKS URL in server config
Unsanitised kid value SQL injection / path traversal Validate kid against allowlist

Wrapping Up: Next Steps

JWT security is not difficult once you know where the traps are. Most exploitable bugs come down to trusting input that came from the attacker, and the fixes are straightforward.

Here are four concrete actions to take right now:

  1. Audit your verification calls. Search your codebase for decode or verify calls and confirm each one passes a hardcoded algorithm whitelist. If any reads the algorithm from the token, fix it immediately.
  2. Test your secret strength. If you use HS256, check where the secret comes from. If it's in source code, a config file with weak entropy, or shorter than 32 bytes, rotate it and move it to a secrets manager.
  3. Add claims validation. Confirm your decode calls pass audience and issuer when your identity provider issues those claims. Check that expiry verification is enabled.
  4. Review JWKS handling. If you use RS256 with JWKS, verify the endpoint URL is pinned in configuration, not read from tokens. Add input validation for kid values before using them in lookups.
  5. Check your library version. JWT libraries patch insecure defaults over time. Pin a recent version and read the changelog for your specific library — breaking changes in verification behavior are common between major versions.

Frequently Asked Questions

Can an attacker forge a JWT without knowing the secret key?

Yes, in several scenarios. The alg:none attack bypasses signature verification entirely by setting the algorithm to none and removing the signature. The RS256-to-HS256 confusion attack lets an attacker use your publicly available RSA public key as an HMAC secret. Both work without ever learning your private key.

Does verifying the JWT signature mean the token is fully trusted?

No. A valid signature only proves the token was created by someone with the signing key. You still need to verify the expiry (exp), issuer (iss), and audience (aud) claims to prevent token replay attacks and cross-service misuse.

How short is too short for an HMAC secret used with HS256?

Any secret that can be found in a dictionary or is shorter than 256 bits (32 bytes) of random data is dangerously weak. Tools like hashcat can test millions of candidates per second against a captured token offline, so entropy matters much more than password-style complexity rules.

What is the safest algorithm choice for new JWT-based APIs?

RS256 (RSA with SHA-256) is generally preferred for APIs. Because the private key never leaves your auth server and the public key is distributed for verification, there is no shared secret to brute-force or accidentally expose. It also makes key rotation easier with JWKS.

Why would a JWT library accept the alg:none value at all?

The JWT specification includes none as a valid algorithm for cases where the message integrity is guaranteed by an outer mechanism, such as a TLS-secured channel between trusted internal services. The problem is that libraries historically implemented this as a global option rather than requiring explicit opt-in, so it worked on endpoints that had no intention of using it.

📤 Share this article

Sign in to save

Comments (0)

No comments yet. Be the first!

Leave a Comment

Sign in to comment with your profile.

📬 Weekly Newsletter

Stay ahead of the curve

Get the best programming tutorials, data analytics tips, and tool reviews delivered to your inbox every week.

No spam. Unsubscribe anytime.