Security

JWT Security Pitfalls: The Mistakes That Keep Breaking Tokens

May 24, 2026 10 min read Haven Team

JSON Web Tokens look deceptively simple. Three base64-encoded segments, a signature, and you're authenticating users across a distributed system. The problem is that the format hands authors enough rope to hang an entire application — and the same handful of mistakes keep showing up in CVE feeds year after year.


A JWT is three dot-separated chunks: a header describing the signature algorithm, a payload of claims (subject, issuer, expiry, custom data), and a signature over the first two. RFC 7519 defined the structure in 2015 and it became the default token format for OAuth, OpenID Connect, and roughly every "stateless authentication" tutorial since.

Most of the trouble lives not in the spec itself but in the libraries that implement it — and in the validation code developers write around them. The fundamental issue is that JWTs are flexible: they support multiple algorithms, optional claims, and several legitimate use cases. Each axis of flexibility is a place where a mistake can let a forged token through.

The "alg: none" Trap

The JWT spec includes an algorithm called none. It's intended for tokens that don't need cryptographic integrity — a stretch of use case that has never made sense in practice. When the algorithm is none, the signature segment is empty. A token with {"alg":"none"} in its header has no signature to verify, and a naive library will accept it.

The attack writes itself. Take a legitimate token, modify the payload to escalate privileges, change the header's alg to none, and submit it. If the server's verifier doesn't explicitly reject none, the token validates.

Mitigation

Every modern JWT library lets you pin the accepted algorithms. Always pass an explicit list — ["HS256"] or ["RS256"] — never trust the library default. If your code path includes none as accepted, that is the bug.

Several widely-used libraries shipped with none accepted by default until around 2018. The class of bug is fixed in current versions, but old deployments persist, and the lesson generalizes: trusting the token's own description of how to verify it is the original sin of JWT validation.

The Algorithm Confusion Attack

A subtler version of the same problem. JWTs support both symmetric algorithms (HMAC variants — HS256, HS384, HS512) and asymmetric ones (RSA — RS256, etc., and ECDSA — ES256, etc.). A symmetric algorithm uses a single shared secret; an asymmetric one uses a private key to sign and a public key to verify.

If a server expects RS256 — verifying tokens with an RSA public key — but a library trusts the alg field, an attacker can craft a token with "alg":"HS256" and sign it with the server's public key as the HMAC secret. The public key is, by definition, public. The library, asked to "verify HS256 with the configured key," dutifully validates the HMAC. The token is accepted.

This is the algorithm confusion attack, and it's the canonical reason you must pin algorithms at validation time. The library cannot decide what algorithm is acceptable; the application must.

Skipping Signature Verification Entirely

Most JWT libraries have a decode() function that parses the token without verifying the signature, and a separate verify() function that does both. The decode() function exists for legitimate reasons — inspecting a token for debugging, reading the kid header to look up the correct key — but it has a habit of finding its way into production code paths where verification was the actual intent.

The symptom is code that "works" — claims parse correctly, the user ID is present, requests succeed — while never actually validating the signature. A forged token with arbitrary claims gets accepted indistinguishably from a legitimate one. The bug is silent and code review catches it only if reviewers know the API surface.

One memorable production failure mode: a refactor moved authentication to a middleware layer; the new layer called decode() instead of verify(); tests passed because the test fixtures had valid signatures; deployment passed because the integration tests didn't include any forged tokens. The bug was found by a security audit eight months later.

Missing Claim Validation

A valid signature proves the token wasn't forged. It doesn't prove the token is currently valid, was issued by the right party, or is being used by the right service. Those are claim-level checks, and they're the developer's responsibility.

Claim Check Failure mode if skipped
exp (expiry) Reject if past current time, with clock-skew tolerance Stolen tokens never expire
nbf (not-before) Reject if before current time Pre-dated tokens accepted
iss (issuer) Reject if not your trusted issuer Tokens from another tenant or service accepted
aud (audience) Reject if not your service's identifier Tokens intended for a different API accepted
iat (issued-at) Sanity-check it's not far in the future Replay window confusion

Audience validation in particular is widely skipped. If your authorization server issues tokens for three services and only one of them checks the aud claim, a token issued for one service can be replayed against the others. This was the root cause of several high-profile breaches in OAuth-based architectures.

JWKS Key Confusion

Asymmetric JWTs are usually verified by fetching the issuer's public keys from a JWKS (JSON Web Key Set) endpoint, often discovered through OpenID Connect metadata. The token carries a kid (key ID) in its header indicating which key to use.

Two recurring bugs here. First, libraries that fetch the JWKS over plain HTTP — a network attacker can swap in their own keys. Always use HTTPS, and ideally cache the keys with a short TTL. Second, applications that trust an arbitrary jku (JWK Set URL) header from the token itself — an attacker provides a URL pointing to a key set they control, and the library cheerfully fetches and uses it. The jku header was never safe to trust without an allowlist, but the spec doesn't make this obvious.

The "Stateless" Lie

Half the appeal of JWTs in marketing material is statelessness: the server doesn't need a session database, just a signing key. This works fine until you need to log a user out.

A signed JWT is valid until its exp claim says otherwise. If a user's account is compromised, or they hit "log out," the token still cryptographically validates. The options are:

The first and third approaches are the modern norm. Long-lived JWTs — say, 24-hour access tokens — without a revocation mechanism are a security liability disguised as a feature.

What to Use Instead, When You Can

For server-to-server authentication, mTLS often removes the entire bearer-token attack surface. For user-facing authentication, opaque session tokens stored in a database — old-school session cookies — have the property that revocation is trivial and the token itself reveals nothing.

JWTs make sense when you genuinely need self-contained claims that downstream services must verify without consulting the issuer. OpenID Connect's ID tokens fit this; cross-service authorization in a microservice mesh fits this. For "logged in user has a session," they're often overkill — and the operational complexity costs more than it saves.

The Validation Checklist Worth Printing

If you must use JWTs, the non-negotiable checks before trusting any claim:

  1. Pin the algorithm. Pass an explicit allowed list to your verifier. Never accept none.
  2. Verify the signature. Use verify(), not decode().
  3. Check exp and nbf with a reasonable clock-skew tolerance (a minute is generous).
  4. Check iss and aud against an allowlist your application defines.
  5. Fetch keys over HTTPS from a hard-coded JWKS URL. Never honor jku or x5u headers from the token.
  6. Use a current library version. Many older versions have CVEs for the bugs above.
  7. Have a revocation story. Short-lived access tokens or a denylist. Pick one.

Most JWT vulnerabilities are not algorithm breaks — they're implementation oversights at the validation layer. The cryptography is fine; the contract around it is the dangerous part. As with most security primitives, the failure mode is rarely the math.

Try Haven free for 15 days

Encrypted email and chat in one app. No credit card required.

Get Started →