All posts
Security

Your JWT Validation Is Broken. Here Are the Eight Bugs That Caused Real Breaches.

JWTs look simple: a signed JSON blob, verify the signature, trust the claims. Almost every step of that has a known bug pattern that has caused real production breaches. Here is the catalog.

By Sharon Sahadevan··11 min read

A JSON Web Token is structurally simple. Three base64-encoded parts joined by dots. Header, payload, signature. Decode the first two, verify the third, trust the claims. What could go wrong?

In the last decade: a lot. Auth0 had a JWT bypass in 2020. Atlassian had one in 2018. The alg: none vulnerability cost dozens of services breaches between 2015 and 2017. Several JWT libraries have had key-confusion bugs. The pattern is consistent: validation looks correct, fails one specific check, attacker walks in.

This post is the list of real JWT validation bugs that have caused real breaches, in roughly the order an attacker explores them. If your code does any of these, the rest of this post is the fix.

The structure: a quick refresher#

A JWT looks like this:

eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjEifQ.
eyJzdWIiOiJ1c2VyXzEyMyIsImF1ZCI6Im15LWFwaSIsImlzcyI6Imh0dHBzOi8vYXV0aC5leGFtcGxlLmNvbSIsImV4cCI6MTczNDU2NzgwMH0.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

Three parts separated by dots:

  • Header: {"alg":"RS256","typ":"JWT","kid":"1"} (the algorithm and key ID).
  • Payload: {"sub":"user_123","aud":"my-api","iss":"https://auth.example.com","exp":1734567800} (the claims).
  • Signature: cryptographic signature over header + payload, using the algorithm specified in the header.

Validating a JWT means: decode header, look up the key by kid, verify the signature using the algorithm in alg, then check the claims (issuer, audience, expiry, etc.).

Each step has been the location of real bugs.

Bug 1: Accepting alg: none#

The original sin of JWT vulnerabilities. The JWT spec defined alg: none for "unsigned tokens" (legitimate in some cases like internal token transit). Attacker submits a token with header {"alg":"none"} and an empty signature. Library validates: "alg is none, so I do not need to verify a signature, just decode the claims." Done. Attacker controls the entire claims set.

# An attacker-supplied token
header = '{"alg":"none","typ":"JWT"}'
payload = '{"sub":"admin","aud":"my-api","iss":"https://auth.example.com","exp":9999999999}'
token = b64(header) + "." + b64(payload) + "."
# Note: empty signature

# Vulnerable code
import jwt
claims = jwt.decode(token, verify=False)  # or with old library defaults

Fix: explicitly require an algorithm. In modern libraries this is the default, but always be explicit:

claims = jwt.decode(
    token,
    key=public_key,
    algorithms=["RS256"],   # explicitly allow only RS256
    audience="my-api",
    issuer="https://auth.example.com",
)

Most modern libraries reject alg: none by default now. Older libraries (PyJWT < 2.0, jsonwebtoken < 8.x, jose < 4.x) had defaults that did not. If you support them, audit explicitly.

Bug 2: Algorithm confusion (RS256 to HS256)#

A worse variant of the previous: the attacker keeps a "real" algorithm but switches between asymmetric and symmetric. RS256 verifies with a public key (anyone can have it). HS256 verifies with a shared secret (HMAC).

The bug: a service is configured to verify RS256 tokens with the public key. Attacker submits a token with header {"alg":"HS256"} instead. The verification library uses the public key (which is meant to be the asymmetric public key) but treats it as an HMAC secret, because the header said "HS256." If the library uses whatever-key-it-has for whichever-algorithm-the-header-says, the attacker can forge tokens by signing them with HMAC using the public key (which the attacker has, because public keys are public).

# Attacker fetches the public key
$ curl https://auth.example.com/.well-known/jwks.json
{"keys": [{"kty": "RSA", "n": "...", "kid": "1"}]}

# Attacker signs a token with HS256 using that public key as the HMAC secret
import jwt
forged = jwt.encode(
    {"sub": "admin", "aud": "my-api", "iss": "https://auth.example.com", "exp": 9999999999},
    key=public_key_pem,   # the public key
    algorithm="HS256",
)

# Vulnerable verifier
jwt.decode(forged, key=public_key_pem)  # accepts HS256 verification with the public key

Fix: same as Bug 1. Always specify an explicit algorithms list. Never let the token's header dictate which algorithm is used:

jwt.decode(token, key=public_key, algorithms=["RS256"])  # only RS256, period

Pin the algorithm to whatever your auth server actually uses. If you support both RS256 and ES256, list both. Never include both an asymmetric algorithm and a symmetric one.

Bug 3: No audience check#

The aud claim names which service the token was intended for. A token with aud: ["my-api"] is meant for my-api. If my-other-api accepts the token without checking the audience, the auth server's token can be replayed across services that should not trust each other.

This is one of the most common cross-service privilege escalation vectors in microservice architectures. Service A's token gets stolen (legitimate, expected) and works against Service B (illegitimate, not expected) because Service B never checks aud.

# Token intended for service-a
{"sub": "user", "aud": "service-a", "iss": "...", "exp": ...}

# Service B's vulnerable check
claims = jwt.decode(token, key=public_key, algorithms=["RS256"])
user = claims["sub"]   # accepts any token, doesn't check aud

Fix: always validate aud against your service's expected audience:

claims = jwt.decode(
    token,
    key=public_key,
    algorithms=["RS256"],
    audience="service-b",   # this MUST match the aud claim
)

The library should raise on audience mismatch. If your library does not (some older ones), add the check manually.

Bug 4: No issuer check#

The iss claim names who issued the token. Without an issuer check, a JWT signed by https://attacker.example.com (with that issuer's "valid" key) is accepted as long as it is well-formed.

This typically happens when a service trusts a federated IdP (e.g., "any Google account"). Without pinning the issuer, an attacker's own Google-issued token (for their own account) is treated as "a valid Google token" and the service has no way to know which Google account.

Fix: pin the issuer:

claims = jwt.decode(
    token,
    key=public_key,
    algorithms=["RS256"],
    audience="my-api",
    issuer="https://auth.example.com",   # only this issuer
)

For multi-tenant SaaS: you might accept multiple issuers, but each must be in an explicit allow list, not "any iss is OK."

Bug 5: Skipping expiration check#

The exp claim is the token's expiration time. Some libraries do not check this by default; some make it optional; some require you to call a separate function. Result: a stolen token works forever.

# Vulnerable: not checking exp
claims = jwt.decode(token, key=public_key, algorithms=["RS256"], options={"verify_exp": False})
# Or older libraries that don't check by default

Fix: always check exp. Modern libraries do this by default.

For long-running operations that span the token's expiry: do NOT extend the token in-app. Either get a new token (via refresh token, if the flow has one) or rely on short-lived tokens with proper renewal.

Also check nbf (not before) if your token has it: the token is not valid before that time. Rare, but worth knowing.

Bug 6: Clock skew not handled#

Token expiry is a Unix timestamp. Token says "expires at 14:30:00 UTC." If your server's clock is 30 seconds ahead of the auth server's clock, you'll reject tokens that the auth server still considers valid. If your clock is 30 seconds behind, you'll accept tokens that should have expired.

In distributed systems with multiple clocks (auth server, your service, the user's machine), tiny skew is normal. A token issued at 14:00:00.000 with a 1-hour lifetime will have exp = 14:59:59.999 (call it). At 14:59:59.500 your-time, the token is "still valid"; at 15:00:00.001 your-time, it is expired. If your clock is 1 second behind, you'll keep accepting it for an extra second.

Fix: configure a small leeway (typically 30-60 seconds) in your validator:

claims = jwt.decode(
    token,
    key=public_key,
    algorithms=["RS256"],
    audience="my-api",
    issuer="https://auth.example.com",
    leeway=30,   # accept tokens up to 30s past exp
)

Pair with NTP synchronization on every host. Skew of more than a few seconds is a signal that something is wrong (host clock drift, time zone bug, container with no NTP).

Bug 7: JWKS rotation not handled#

Auth servers rotate signing keys periodically (every 30 days is typical). The JWKS endpoint (https://auth.example.com/.well-known/jwks.json) lists the currently-valid keys with their kid (key IDs). Tokens include kid in the header so verifiers know which key to use.

The bugs:

A) Verifier caches JWKS forever. Your service fetches /.well-known/jwks.json once at startup. Auth server rotates keys; your service still has the old one; tokens signed with the new key are rejected. You think tokens are bad; the auth server thinks they are fine.

B) Verifier ignores kid. Tries every key in the JWKS for every token. Works, but wastes CPU on the wrong keys, and obscures bugs.

C) Verifier auto-refreshes JWKS but does not handle the rotation overlap. During rotation, both the old and new key are valid (tokens signed before rotation should still work for their lifetime). A verifier that immediately replaces the old key with the new one rejects pre-rotation tokens.

Fix: use a JWKS-aware library that:

  • Caches JWKS for a reasonable period (5-10 minutes).
  • Refreshes the cache when an unknown kid is seen in a valid-looking token.
  • Keeps multiple keys around during rotation.

Most modern libraries do this. python-jose, jose-jwt, jsonwebtoken (Node), lestrrat-go/jwx (Go) all have JWKS-aware verifiers.

// Go example using jwx
import "github.com/lestrrat-go/jwx/v2/jwk"
import "github.com/lestrrat-go/jwx/v2/jwt"

cache := jwk.NewCache(ctx)
cache.Register("https://auth.example.com/.well-known/jwks.json")
keyset, _ := cache.Get(ctx, "https://auth.example.com/.well-known/jwks.json")

token, err := jwt.Parse(
    rawToken,
    jwt.WithKeySet(keyset),
    jwt.WithIssuer("https://auth.example.com"),
    jwt.WithAudience("my-api"),
    jwt.WithAcceptableSkew(30 * time.Second),
)

Bug 8: Trusting JWT for everything#

The deepest mistake: treating JWT as the be-all of auth. JWTs are a transport for short-lived authentication assertions. They have known limitations:

  • They cannot be revoked. Once issued, a JWT is valid until its exp. If a user logs out, an attacker who has their JWT can keep using it. Unless you maintain a server-side revocation list (which defeats much of the JWT model), you cannot stop a stolen JWT before it expires.
  • They are not encrypted by default. Anyone who sees the JWT in transit can decode the claims. The signature only proves authenticity, not confidentiality. Do not put sensitive data in the claims unless you also use JWE (encrypted JWT).
  • Token-bound permissions go stale. A JWT issued with "admin" scope keeps that scope until expiry, even if you remove the user from admins one minute later.

Fixes (situation-dependent):

  • Short-lived access tokens (15-30 minutes), with refresh tokens for longer-lived persistence. Stolen access tokens become useless quickly.
  • Sender-constrained tokens (DPoP, mTLS-bound). Token only works if the holder also presents a matching key/cert. Stolen tokens are useless without the key.
  • Revocation list for high-stakes operations. Maintain a list of recently-revoked token IDs (jti claim). Check on every request for sensitive endpoints. Trade off the JWT-no-state property for revocation capability where it matters.
  • Re-validate authorization on every call. The token says who you are; check authorization fresh against your DB rather than trusting embedded scopes for long-lived sessions.

Defensive validation: the full check list#

A well-validated JWT has all of these confirmed:

claims = jwt.decode(
    token,
    key=public_key,                         # Bug 1, 2: explicit key
    algorithms=["RS256"],                   # Bug 1, 2: explicit alg
    audience="my-api",                      # Bug 3: pin audience
    issuer="https://auth.example.com",      # Bug 4: pin issuer
    options={
        "verify_signature": True,
        "verify_exp": True,                 # Bug 5: check expiration
        "verify_aud": True,
        "verify_iss": True,
        "require": ["exp", "aud", "iss", "sub"],   # require the fields
    },
    leeway=30,                              # Bug 6: clock skew
)

# Bug 7: JWKS-aware key resolution (use a JWKS-aware library, not raw key)
# Bug 8: don't trust JWT for revocation; check revocation list for sensitive ops
if claims["jti"] in revocation_list and is_sensitive_endpoint:
    raise UnauthorizedError()

Plus, for production:

  • Log every validation failure (with the failure reason: bad sig, expired, wrong aud, wrong iss). Patterns of bad-aud or bad-iss indicate an attacker probing.
  • Alert on validation failures spiking. A wave of "alg=none" attempts is unmistakable.
  • Use a vetted library, never roll your own. Cryptographic code is unforgiving.

Quick reference: the JWT validation checklist#

For every JWT your service accepts:

1. Verify signature.
2. Pin algorithm (algorithms=["RS256"], etc., never let header dictate).
3. Pin issuer (iss must match expected).
4. Pin audience (aud must include your service).
5. Check expiration (exp + small leeway).
6. Check not-before if present (nbf).
7. Use a JWKS-aware library that handles key rotation.
8. For sensitive operations, also check a revocation list (jti).
9. Log failures with reason; alert on spikes.
10. Never put sensitive data in claims unless using JWE.

Hard rules:
- alg=none: rejected always.
- HS256 mixed with RS256 in the same allow-list: never.
- Verifier without explicit aud and iss check: never.
- Verifier without explicit algorithms list: never.

The mental model#

JWT is a useful primitive but a sharp one. The validation steps look optional one by one; collectively they are not. Real breaches have happened from skipping each one.

The right reflex when you see JWT validation in a code review: read the four lines that should be there (alg, iss, aud, exp) and look for any of the bug patterns above. Most bugs are in libraries that are decade-old or in code written before understanding what each step actually defends.

Modern libraries make the safe defaults the easy ones. Use them. Pin everything. Log everything. Assume an attacker has tried alg: none against your endpoint by lunch tomorrow.


JWT, OIDC, and the broader auth landscape are covered in detail in the Kubernetes Security course, where ServiceAccount tokens, OIDC federation, and impersonation all build on these primitives. The cryptographic foundations (RSA, ECDSA, key rotation, JWKS) are part of the SSL/TLS Certificate Management course.

More in Security