X Xerobit

JWT Security Best Practices — Common Vulnerabilities and How to Avoid Them

JWT vulnerabilities include algorithm confusion attacks, weak secrets, missing expiry, and storing tokens in localStorage. Learn how to implement JWTs securely: algorithm...

Mian Ali Khalid · · 5 min read
Use the tool
JWT Decoder
Decode and inspect JSON Web Tokens. Local-only — tokens never leave your browser.
Open JWT Decoder →

JWT security requires knowing the attacks — the ‘none’ algorithm exploit, algorithm confusion between HS256 and RS256, weak secrets, and improper token storage are the most common vulnerabilities.

Use the JWT Decoder to inspect JWT headers and payloads.

The ‘none’ algorithm attack

Some early JWT libraries allowed alg: none, which meant no signature:

// Attacker creates a token with alg: none and admin claim:
// Header: {"alg":"none","typ":"JWT"}
// Payload: {"sub":"123","role":"admin","exp":9999999999}
// Signature: (empty)

// VULNERABLE: accept 'none' algorithm:
jwt.verify(token, secret, { algorithms: ['HS256', 'none'] })  // BAD!

// SECURE: whitelist only specific algorithms:
jwt.verify(token, secret, { algorithms: ['HS256'] })  // Good

Fix: Always specify allowed algorithms explicitly.

Algorithm confusion (HS256 vs RS256)

An RS256 JWT is signed with an RSA private key and verified with the public key. If a library uses the public key as the HMAC secret when it sees HS256, an attacker who knows the public key can forge tokens:

// VULNERABLE code pattern:
const secret = getKeyFromHeader(decodedHeader.alg);  // Never do this!

// Attack: attacker takes the public RSA key (which is public!) and signs
// a fake token with HS256 using that public key as the HMAC secret

// SECURE: hard-code the expected algorithm:
jwt.verify(token, publicKey, { algorithms: ['RS256'] });
// NOT: jwt.verify(token, keyLookup(header.alg))

Weak secrets

HMAC JWT security depends entirely on secret entropy:

// WEAK — short, predictable secrets:
const secret = 'secret';       // trivially crackable
const secret = 'password123';  // dictionary attack
const secret = 'jwt-secret';   // common string

// SECURE — random, 256+ bits:
import crypto from 'crypto';
const secret = crypto.randomBytes(32).toString('base64url');
// Example: 'Yx8kJ2mNpQrTsUvWx9yZaB3cD4eF5gH6iJ7kL8mN9o'

// Store in environment variable:
process.env.JWT_SECRET = secret

JWT crackers like jwt-cracker and hashcat can brute-force weak secrets offline.

Missing or far-future expiry

// VULNERABLE: no expiry
jwt.sign({ userId: 123 }, secret);
// Token valid forever!

// VULNERABLE: very long expiry
jwt.sign({ userId: 123 }, secret, { expiresIn: '365d' });
// Token valid 1 year after compromise

// SECURE: short-lived access tokens
jwt.sign({ userId: 123 }, secret, { expiresIn: '15m' });
// + refresh token mechanism for staying logged in

Validate all claims

// VULNERABLE: only verifying signature
const decoded = jwt.verify(token, secret);
// Doesn't check issuer, audience, or custom claims

// SECURE: verify signature + all expected claims
const decoded = jwt.verify(token, secret, {
  algorithms: ['HS256'],
  issuer: 'https://auth.yourapp.com',
  audience: 'https://api.yourapp.com',
});

// Then verify app-specific claims:
if (!decoded.sub || !decoded.role) {
  throw new Error('Missing required claims');
}
// VULNERABLE: localStorage (XSS can steal it)
localStorage.setItem('jwt', token);

// SECURE option 1: httpOnly cookie (XSS-resistant, but needs CSRF protection)
res.cookie('accessToken', token, {
  httpOnly: true,   // JavaScript cannot read it
  secure: true,     // HTTPS only
  sameSite: 'strict',  // CSRF protection
  maxAge: 15 * 60 * 1000,
});

// SECURE option 2: memory only (lost on refresh, use with refresh token)
// Store in a module-level variable, not in browser storage
let accessToken = null;

CSRF protection with cookies

If using cookies, protect against Cross-Site Request Forgery:

// SameSite=Strict is the strongest CSRF protection:
res.cookie('jwt', token, { sameSite: 'strict', ... });

// SameSite=Lax allows top-level navigation (links from other sites):
res.cookie('jwt', token, { sameSite: 'lax', ... });

// Double-submit cookie pattern (for SameSite=None):
// Set a non-httpOnly random token in cookie + require it in request header

Key rotation

// Use key IDs (kid) in JWT header for rotation:
const currentKey = {
  id: 'key-2026-01',
  secret: process.env.JWT_SECRET_2026_01,
};

// Sign with current key:
const token = jwt.sign(payload, currentKey.secret, {
  algorithm: 'HS256',
  keyid: currentKey.id,
  expiresIn: '15m',
});

// Verify: look up key by kid
const keyStore = {
  'key-2026-01': process.env.JWT_SECRET_2026_01,
  'key-2025-12': process.env.JWT_SECRET_2025_12,  // Previous key (still valid during rotation)
};

function verifyToken(token) {
  const decoded = jwt.decode(token, { complete: true });
  const secret = keyStore[decoded.header.kid];
  if (!secret) throw new Error('Unknown key');
  return jwt.verify(token, secret, { algorithms: ['HS256'] });
}

Related posts

Related tool

JWT Decoder

Decode and inspect JSON Web Tokens. Local-only — tokens never leave your browser.

Written by Mian Ali Khalid. Part of the Dev Productivity pillar.