← Blog/Authentication

JWT Security Best Practices: 8 Mistakes That Expose Your API

JWTs are everywhere — and so are JWT vulnerabilities. Most of these mistakes are invisible in code review and only exploitable at runtime. Here's what to avoid, and what to do instead.

·9 min read

JSON Web Tokens are the dominant authentication mechanism for modern APIs. Libraries like jsonwebtoken, jose, and next-auth make them trivially easy to implement. Which is exactly the problem — it's easy to implement JWT auth in a way that looks correct but is fundamentally broken from a security standpoint.

These eight JWT security mistakes are the ones we see most often in startup-scale production APIs. Some are well-known. A few are subtle enough to catch experienced developers off guard.

Mistake 1: Accepting the none Algorithm

This is the most dangerous JWT vulnerability, and it's been in the wild since JWT was standardized. The JWT spec allows an algorithm value of none, meaning no signature is required. Some JWT libraries honor this by default.

An attacker can take any valid JWT, change the payload to claim admin privileges, set alg: none, strip the signature, and submit it. If your library accepts it, the attacker now has admin access.

Fix: Explicitly specify the allowed algorithm(s) in your JWT verification configuration. Never accept none. In jsonwebtoken: pass { algorithms: ['RS256'] } to jwt.verify(). In jose: the algorithm is explicit in the verification function signature.

Mistake 2: Using a Weak Signing Secret

If you're using HMAC-based JWT (HS256, HS384, HS512), your security is entirely dependent on the strength of your signing secret. A short, guessable, or reused secret can be cracked offline if an attacker captures a valid JWT.

Common bad secrets: secret, password, jwt_secret, app names, 8-character strings, and anything you typed quickly during initial setup. We've seen production apps using test123 as their JWT secret.

Fix: Generate a cryptographically random secret of at least 256 bits (32 bytes). In Node.js: crypto.randomBytes(32).toString('hex'). Store it in an environment variable, never in code. For new apps, prefer RS256 (asymmetric) over HS256 — the private key stays on your server and can't be brute-forced from a captured token.

Mistake 3: No Token Expiry

A JWT with no exp claim is valid forever. If it's ever stolen — through XSS, log scraping, an insecure third-party service — there's no automatic expiry to limit the damage. The stolen token grants access indefinitely.

Fix: Always set an exp claim. Access tokens should expire in 15–60 minutes. Use refresh tokens (stored in httpOnly cookies, not localStorage) for session persistence. The short expiry window limits the blast radius of any token compromise.

Mistake 4: Storing Sensitive Data in the Payload

The JWT payload is base64-encoded, not encrypted. Anyone who has your token can decode it and read the payload — no secret needed. This is by design: JWTs are signed (for integrity), not encrypted (for confidentiality).

We've seen JWTs containing plaintext passwords, API keys, credit card numbers, full user objects with PII, and internal system identifiers that help attackers enumerate resources. All of this was visible to anyone who captured the token.

Fix: Only put non-sensitive identifiers in the JWT payload: user ID, role, session ID. If you need to pass sensitive data with authentication context, use server-side sessions with a session ID in the JWT, or use JWE (JSON Web Encryption) for the rare cases where payload encryption is genuinely necessary.

Mistake 5: Not Validating the Token Signature at All

This sounds like something that couldn't happen, but it does — particularly when developers implement JWT handling manually, use the wrong library method, or decode a token for payload inspection and forget to also verify it.

The pattern looks like: const payload = jwt.decode(token) instead of const payload = jwt.verify(token, secret). The decode() method in most JWT libraries does not verify the signature — it just reads the payload. An attacker can forge any payload they want.

Fix: Always use verify(), never decode(), for tokens that control authorization. If you need to inspect a token before verifying (e.g., to extract the key ID for RS256), verify immediately after. Make signature verification part of your middleware, not an afterthought.

Mistake 6: Algorithm Confusion (RS256 → HS256 Downgrade)

This is a subtle but devastating attack. When using RS256, you sign with a private key and verify with the public key. If your JWT library accepts both RS256 and HS256, an attacker can take your public key (which is, by definition, public) and use it as the secret to create a valid HS256-signed token.

Your library thinks it's verifying an HS256 token with the (known) public key as the secret — and it succeeds. The attacker has created a validly-signed token without knowing your private key.

Fix: Explicitly specify allowed algorithms in your verify call. Never allow both RS256 and HS256 for the same token type. If you use RS256, reject HS256 entirely.

Mistake 7: Storing JWTs in localStorage

JWTs stored in localStorage are accessible to any JavaScript running on your page — including injected scripts from XSS vulnerabilities. This makes localStorage token storage incompatible with Content Security Policy and creates a standing risk: if you ever have a single XSS vulnerability anywhere in your app, every user's tokens are exposed.

Fix: Store JWTs in httpOnly cookies. These are inaccessible to JavaScript entirely and are automatically sent with requests. Pair with Secure (HTTPS only) and SameSite=Strict or SameSite=Lax to prevent CSRF. The combination of httpOnly + Secure + SameSite is significantly more robust than localStorage for token storage.

Mistake 8: No Token Revocation Strategy

Pure stateless JWTs can't be revoked before they expire. If a user logs out, their token is still valid until the exp timestamp. If a token is stolen, there's no way to invalidate it. If an admin needs to immediately terminate a session (suspected account compromise, GDPR deletion request), there's no mechanism to do so.

This is an inherent trade-off in stateless auth. Most startups accept it by keeping expiry times short. But for any app handling sensitive data, you need a revocation strategy.

Fix: Implement a token blocklist (Redis is ideal — fast lookups, automatic TTL-based cleanup) or use short-lived tokens with a refresh token rotation scheme. On every request, check the blocklist against the token's JTI (JWT ID) claim. On logout or compromise, add the JTI to the blocklist until the token's natural expiry.

JWT Security Checklist

  • none algorithm explicitly rejected in verify configuration
  • ✅ Signing secret is cryptographically random, ≥32 bytes, stored in env vars
  • exp claim set on all tokens; access tokens expire in <60 minutes
  • ✅ No sensitive data (passwords, keys, PII) in JWT payload
  • verify() used everywhere — never decode() for auth decisions
  • ✅ Allowed algorithms explicitly specified — no algorithm confusion risk
  • ✅ Tokens stored in httpOnly Secure SameSite cookies, not localStorage
  • ✅ Revocation strategy: token blocklist or short-lived tokens + refresh rotation

Check your API for authentication and security issues

Free external scan — headers, CORS, SSL, exposed endpoints, API key exposure. 60 seconds.

Scan Your API Free →

Scan Your API Free — 60 Seconds

External security scan catches what code review misses. No signup. No SDK.