DEV Community

Cover image for JWT security mistakes that will get you breached
Tahmid
Tahmid

Posted on

JWT security mistakes that will get you breached

JWTs are everywhere. Auth tokens, API keys, session management — if you're building web apps, you're almost certainly using them. They're also one of the most commonly misconfigured pieces of security infrastructure in production systems.

Here are the mistakes I see repeatedly, and exactly how to fix them.


Quick recap: what a JWT actually is

A JWT has three parts separated by dots:

header.payload.signature
Enter fullscreen mode Exit fullscreen mode
  • Header — algorithm used to sign the token (HS256, RS256, etc.)
  • Payload — the claims (user ID, roles, expiry, etc.)
  • Signature — cryptographic proof the token hasn't been tampered with The payload is base64-encoded, not encrypted. Anyone who intercepts your token can read everything in it. This matters more than most developers realise.

If you want to see this in action, paste any token into a JWT decoder and read the payload in plain text — no tools, no secret needed.


Mistake 1: Using the none algorithm

This is the most catastrophic JWT vulnerability. Some libraries accept a token signed with alg: none — meaning no signature at all.

An attacker can craft a token like this:

// Header (base64 decoded)
{ "alg": "none", "typ": "JWT" }

// Payload (base64 decoded)
{ "sub": "1234", "role": "admin" }
Enter fullscreen mode Exit fullscreen mode

If your server accepts it, the attacker just gave themselves admin access with no credentials.

Fix: Explicitly allowlist the algorithms your server accepts. Never allow none.

// Node.js / jsonwebtoken
jwt.verify(token, secret, { algorithms: ['HS256'] });
Enter fullscreen mode Exit fullscreen mode

Mistake 2: Weak or hardcoded secrets

HS256 is a symmetric algorithm — the same secret signs and verifies tokens. If your secret is weak or leaked, every token ever issued is compromised.

Common offenders:

secret
password
jwt_secret
mysecretkey
your-256-bit-secret   ← literally from the JWT.io example
Enter fullscreen mode Exit fullscreen mode

Fix: Generate secrets with sufficient entropy and store them in environment variables, never in code.

# Generate a strong secret
node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"
Enter fullscreen mode Exit fullscreen mode

For production systems with multiple services, prefer RS256 (asymmetric) — the private key signs, the public key verifies. A compromised verification service can't forge tokens. You can test tokens signed with different algorithms using the JWT encoder to understand exactly what gets embedded.


Mistake 3: Storing JWTs in localStorage

localStorage is accessible to any JavaScript running on your page. A single XSS vulnerability — in your code, a dependency, or a third-party script — exposes every stored token.

Fix: Store JWTs in HttpOnly cookies. They're invisible to JavaScript entirely.

res.cookie('token', jwt, {
  httpOnly: true,    // not accessible via JS
  secure: true,      // HTTPS only
  sameSite: 'strict' // CSRF protection
});
Enter fullscreen mode Exit fullscreen mode

Yes, this requires handling CSRF. That's a better problem to have than XSS token theft.


Mistake 4: No expiry — or expiry that's too long

A JWT with no exp claim is valid forever. If it's leaked, stolen, or the user's account is compromised, you have no way to invalidate it short of rotating your signing secret (which invalidates every token).

Fix: Set short expiry for access tokens, longer for refresh tokens.

const accessToken = jwt.sign(payload, secret, { expiresIn: '15m' });
const refreshToken = jwt.sign(payload, secret, { expiresIn: '7d' });
Enter fullscreen mode Exit fullscreen mode

15 minutes for access tokens is a common production standard. Refresh tokens should be rotated on use and stored server-side so they can be revoked.


Mistake 5: Putting sensitive data in the payload

The payload is not encrypted. It's trivially decoded by anyone with the token:

echo "eyJ1c2VyIjoiYWxpY2UiLCJwYXNzd29yZCI6InNlY3JldCJ9" | base64 -d
# {"user":"alice","password":"secret"}
Enter fullscreen mode Exit fullscreen mode

I've seen production tokens containing passwords, full PII, credit card fragments, and internal system details. All readable by anyone who intercepts the token. Try it yourself — paste that string into a JWT decoder and see exactly what's exposed.

Fix: Put only the minimum identifying information in the payload.

{
  "sub": "user_abc123",
  "role": "admin",
  "exp": 1735689600
}
Enter fullscreen mode Exit fullscreen mode

Look up everything else server-side using the sub claim. If you need the payload encrypted, use JWE (JSON Web Encryption) — a different standard entirely.


Mistake 6: Not validating claims server-side

Libraries verify the signature automatically — but claim validation is on you.

// Bad — only verifies signature
const decoded = jwt.verify(token, secret);

// Good — verify signature AND validate claims
const decoded = jwt.verify(token, secret, { algorithms: ['HS256'] });

if (decoded.exp < Date.now() / 1000) throw new Error('Token expired');
if (decoded.iss !== 'https://yourapp.com') throw new Error('Invalid issuer');
if (decoded.aud !== 'your-api') throw new Error('Invalid audience');
Enter fullscreen mode Exit fullscreen mode

Always validate exp, iss (issuer), and aud (audience) explicitly — especially if tokens from multiple issuers could reach your API.


Mistake 7: No token revocation strategy

JWTs are stateless by design — once issued, they're valid until expiry. But what happens when:

  • A user logs out
  • An account is compromised
  • A user's role changes mid-session If you're relying purely on token expiry, a stolen 15-minute token is valid for up to 15 minutes after the breach is detected.

Fix: Maintain a server-side revocation list (denylist) for sensitive operations.

// On logout
await redis.set(`revoked:${jti}`, '1', 'EX', 900); // expire after token TTL

// On verify
const isRevoked = await redis.get(`revoked:${decoded.jti}`);
if (isRevoked) throw new Error('Token revoked');
Enter fullscreen mode Exit fullscreen mode

Add a jti (JWT ID) claim — a unique identifier per token — so you can target specific tokens for revocation without rotating your secret.


The JWT security checklist

  • Explicitly allowlist algorithms — never allow none
  • Use strong, randomly generated secrets (64+ bytes)
  • Prefer RS256 for multi-service architectures
  • Store tokens in HttpOnly cookies, not localStorage
  • Set short expiry on access tokens (15 minutes)
  • Put only non-sensitive identifiers in the payload
  • Validate exp, iss, and aud claims explicitly

- Implement token revocation with a jti denylist

JWTs are a solid auth primitive when implemented correctly. The problems almost always come from default configurations and library trust — not the spec itself.

What JWT issue have you encountered in production? Drop it in the comments.


Free tools used in this post:

Top comments (0)