What are the most common JWT mistakes?

Direct answer

Five recur: not verifying the signature, trusting the `alg` header from the token itself, no expiry, putting sensitive data in the payload because people assume it's encrypted, and having no way to revoke a token before it expires. The first two are critical, the rest are design problems you inherit for the life of the system.

Muhammad HasanUpdated

Decoding instead of verifying

// Vulnerable
const payload = jwt.decode(token);
if (payload.userId) { /* trusted */ }

decode reads the token without checking the signature. Anyone can construct a token with any payload they like and it will pass.

// Correct
const payload = jwt.verify(token, SECRET);

The two functions sit next to each other in every library's documentation, which is how this keeps happening.

Trusting the alg header

A JWT declares its own algorithm in the header. Libraries that honour that declaration can be manipulated.

Two variants. alg: none says the token is unsigned, and a library that accepts it will trust anything. Algorithm confusion is subtler. A system verifying RS256 with a public key can be sent an HS256 token signed with that public key as the HMAC secret, and a library that picks the algorithm from the header will verify it.

Pin the algorithm explicitly:

jwt.verify(token, SECRET, { algorithms: ['HS256'] });

Modern libraries default to safer behaviour, but this is worth confirming rather than assuming.

The payload is not encrypted

A standard JWT is base64url-encoded JSON. Not encrypted, not obscured, paste one into any decoder and read it.

So email addresses, role names and internal identifiers in a token are visible to anyone holding it, including the user and anyone who gets it from their browser storage. Put an identifier in and look the rest up server-side.

No expiry

A token without an exp claim is valid forever. Set one, and keep it short. Minutes to hours for an access token, with a refresh token handling longer sessions.

The tempting alternative is a long-lived token so users don't get logged out. That's a stolen token that works for a year.

No revocation path

This is the design problem. JWTs are stateless by design, which means verifying one requires no database lookup, which means there's no natural point at which to check whether it's been revoked.

A user changing their password, an admin removing access, a leaked token, none of these invalidate an existing JWT. It stays valid until it expires.

Options, all of which trade away some of the statelessness: short expiry with refresh tokens, a denylist checked on each request, or a per-user token version incremented on logout or password change.

Decide this at design time. Retrofitting revocation into a system that assumed it wasn't needed is unpleasant.

Where you store the token

localStorage is readable by any JavaScript on the page, so an XSS becomes a stolen session. An httpOnly cookie isn't readable by JavaScript, but needs CSRF protection.

Neither is strictly correct. The httpOnly cookie with SameSite=Lax and CSRF tokens is the more defensible default for most web apps.

Reviewing for it

Scanners catch jwt.decode where verification was intended, because it's a distinctive call. They won't tell you your tokens never expire, or that there's no revocation path. Those are design decisions rather than code defects, and they need a person to look.

Go deeper

Kolega for AppSec teams

Related answers

See what your own repository returns

Connect a repo and run a scan. No credit card, no pipeline changes.

Get started for free