JWT Security Mistakes Engineers Keep Making

JWTs are everywhere. These are the five most common security mistakes teams make implementing JWT-based authentication.

Abstract representation of a JWT token structure with security vulnerabilities highlighted

JSON Web Tokens are the default auth mechanism in most modern web applications. They're in virtually every REST API, most mobile app backends, and an increasing number of microservice architectures. They're also consistently misconfigured in code reviews we see. Not through obscure edge cases — through the same six mistakes, repeated across different frameworks, languages, and team sizes.

This isn't a primer on what JWTs are. If you're using them, you know the structure: header, payload, signature, base64-encoded and dot-separated. What follows are the specific implementation mistakes that turn JWTs from a reasonable auth mechanism into a security liability.

Accepting the none algorithm

CVE-2015-9235 documented what became one of the most famous JWT vulnerabilities: many JWT libraries at the time would accept a JWT with the header field "alg": "none" and no signature — treating it as validly signed. An attacker could forge any payload by stripping the signature and changing the algorithm to none.

The immediate fix was straightforward: explicitly specify which algorithms you accept when validating tokens, and never include none in that list. The problem is that the pattern keeps appearing in newer implementations. We still encounter code that passes the algorithm from the token header into the verification function rather than specifying it explicitly:

// Vulnerable: trusts the algorithm from the token header
const decoded = jwt.verify(token, secret, { algorithms: [header.alg] });

// Correct: explicitly specify allowed algorithms
const decoded = jwt.verify(token, secret, { algorithms: ['HS256'] });

If your JWT validation code doesn't have an explicit allowlist of algorithms, you're potentially exposed to algorithm confusion attacks regardless of how old your library is.

Algorithm confusion between RS256 and HS256

This is a subtler version of the same class of problem. RS256 uses an asymmetric key pair: a private key to sign, a public key to verify. HS256 uses a symmetric secret: the same key signs and verifies. Libraries typically support both.

The attack: if a server expects RS256 tokens and uses a public key for verification, an attacker can craft an HS256 token signed with that same public key as the HMAC secret. If the server's verification code doesn't explicitly reject HS256 and the library doesn't enforce algorithm consistency, the token may verify successfully. The public key is, by definition, public — the attacker has it.

The fix is the same: explicitly specify the expected algorithm in your verification call. Never allow the token to dictate which algorithm is used for its own verification.

Storing tokens in localStorage

LocalStorage is accessible to any JavaScript running on your page domain. If your application has a single XSS vulnerability — and XSS is OWASP A03, one of the most common web vulnerabilities — an attacker can steal every JWT from every logged-in user's browser with a script that reads localStorage.getItem('auth_token').

The preferred alternative is storing the JWT in an HttpOnly cookie with the Secure and SameSite=Strict (or SameSite=Lax depending on your flow) attributes. HttpOnly cookies are not accessible to JavaScript. SameSite prevents cross-site request forgery. This doesn't eliminate token theft — it makes it significantly harder to execute at scale.

We're not saying localStorage is always wrong for every kind of token storage. We're saying that for auth tokens in web applications where XSS is a realistic risk — which is most web applications — HttpOnly cookies with appropriate attributes provide substantially better theft resistance.

Not validating critical claims

A JWT is only as trustworthy as its validation. The signature verifies that the token wasn't tampered with after it was issued. It doesn't verify that your application logic checked the claims in the payload.

The exp claim (expiration time) should be validated and the token rejected if expired. The iss claim (issuer) should be validated against expected values, especially if you're accepting tokens from multiple identity providers. The aud claim (audience) should be validated to ensure the token was issued for your application and not for a different service that shares the same signing key.

The pattern we see most often: developers add JWT auth, the library validates the signature successfully, and the application logic never checks exp. Old tokens — including tokens belonging to deactivated users or from previous authentication sessions — remain permanently valid. In some frameworks, the library handles these validations by default; in others, they require explicit calls. Know which one you're using.

Insufficient token revocation

JWTs are stateless by design, which is part of their appeal. But statelessness means there's no built-in mechanism to invalidate a token before it expires. When a user logs out, changes their password, or is deprovisioned, the tokens they hold remain valid until expiration.

For short-lived tokens — 15 minutes or less — this is often an acceptable trade-off. For longer-lived tokens, you need either a token blocklist (a database of revoked token IDs you check on each request) or refresh token rotation with single-use enforcement (each refresh token can only be used once, generating a new refresh token and access token; reuse of an old refresh token triggers invalidation of all tokens in that family).

Refresh token rotation is the approach recommended by the current OAuth 2.0 Security BCP (RFC 9700). If your auth architecture uses long-lived access tokens without a revocation mechanism, that's a finding worth addressing.

Sensitive data in the payload

JWT payloads are base64-encoded, not encrypted. Anyone with the token can decode the payload — they just can't forge a new one. This matters when developers treat the JWT payload as a storage mechanism for sensitive data.

Storing the user's email address, role, or username in a JWT is common and generally fine. Storing PII like date of birth, full address, or government ID — or internal system data like database row IDs for internal models — in the JWT payload means that data is visible to any client that holds the token, to any proxy that logs the Authorization header, and to any attacker who steals the token.

If you need the JWT payload to carry sensitive data, use JSON Web Encryption (JWE) to encrypt the payload. Otherwise, keep the payload minimal: subject identifier (user ID), issued-at, expiration, and any claims your authorization logic requires. Everything else belongs in your session or user lookup.

None of these mistakes require sophisticated exploitation. They're mostly configuration and implementation hygiene issues that show up in code review. A 30-minute security-focused review of your JWT implementation against these six points will surface most of them. The libraries are generally secure; the integration is where things go wrong.