JSON Web Tokens (JWTs) are everywhere — authentication, API authorization, single sign-on. Yet in 2026, JWT-related vulnerabilities remain one of the top attack vectors in web applications. This guide walks through the most common JWT security pitfalls with practical code examples so you can audit and harden your own implementations today.
How JWTs Work: A Quick Refresher
A JWT consists of three Base64URL-encoded parts separated by dots: Header, Payload, and Signature. The server signs the token with a secret or private key, and later verifies the signature to trust the payload.
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NSIsInJvbGUiOiJ1c2VyIn0.SflKxwRJSMeKKF2QT4fwpMThe security of the entire system depends on how you create, verify, and handle these tokens. Let’s look at where things go wrong.
Pitfall 1: The “none” Algorithm Attack
The JWT spec supports "alg": "none", which means no signature at all. If your server blindly trusts the alg header, an attacker can forge any token:
// Malicious token header
{
"alg": "none",
"typ": "JWT"
}
// Attacker sets role to admin, signature is empty
// eyJhbGciOiJub25lIn0.eyJzdWIiOiIxIiwicm9sZSI6ImFkbWluIn0.The Fix
Always enforce the algorithm on the server side. Never let the token dictate which algorithm to use.
// Node.js with jsonwebtoken
const jwt = require('jsonwebtoken');
// ✅ Explicitly specify allowed algorithms
const decoded = jwt.verify(token, SECRET_KEY, {
algorithms: ['HS256'] // reject everything else
});
// ❌ NEVER do this
const decoded = jwt.verify(token, SECRET_KEY); // trusts alg headerPitfall 2: Algorithm Confusion (RS256 → HS256)
If your server uses RS256 (asymmetric — public/private key pair), an attacker can switch the header to HS256 and sign the token with the public key (which is often publicly available). If the server doesn’t enforce the algorithm, it will verify the HMAC signature using the public key as the secret — and it will pass.
# Attack using Python PyJWT (older vulnerable version)
import jwt
public_key = open('public.pem').read()
forged = jwt.encode(
{'sub': '1', 'role': 'admin'},
public_key,
algorithm='HS256'
)
# Server using RS256 but not enforcing → accepts this token!The Fix
# Python — always pin the algorithm
decoded = jwt.decode(
token,
public_key,
algorithms=['RS256'] # ✅ Reject HS256 tokens
)Pitfall 3: Weak or Brute-Forceable Secrets
With HS256, the security is only as strong as your secret. Short or dictionary-based secrets can be cracked offline using tools like hashcat or jwt-cracker.
# Brute-force a weak JWT secret with hashcat
hashcat -a 0 -m 16500 jwt_token.txt wordlist.txt
# jwt-cracker (Node.js tool)
npx jwt-cracker -t eyJhbGciOiJIUzI1NiJ9... -a abcdefghijklmnop -l 6
# Cracks 'secret' in secondsThe Fix
- Use a minimum 256-bit (32-byte) random secret for HS256
- Generate it properly:
openssl rand -base64 32 - Better yet, use RS256/ES256 with proper key management
- Rotate secrets periodically and support multiple valid keys during rotation
Pitfall 4: Missing or Excessive Expiration
Tokens without exp (expiration) live forever. Tokens with 30-day expiry are nearly as bad — a stolen token gives an attacker a month-long window.
// ✅ Short-lived access tokens + refresh token pattern
const accessToken = jwt.sign(
{ sub: userId, role: 'user' },
SECRET_KEY,
{ expiresIn: '15m' } // 15 minutes max
);
const refreshToken = jwt.sign(
{ sub: userId, type: 'refresh' },
REFRESH_SECRET,
{ expiresIn: '7d' } // stored securely, rotated on use
);Always verify expiration server-side and reject expired tokens immediately.
Pitfall 5: Storing Sensitive Data in the Payload
JWTs are signed, not encrypted. Anyone can decode the payload:
echo 'eyJzdWIiOiIxIiwiZW1haWwiOiJ1c2VyQGV4YW1wbGUuY29tIiwic3NuIjoiMTIzLTQ1LTY3ODkifQ' | base64 -d
# Output: {"sub":"1","email":"user@example.com","ssn":"123-45-6789"}Never put passwords, SSNs, credit card numbers, or any PII in a JWT payload. Include only the minimum: user ID, role, and expiration.
Pitfall 6: No Token Revocation Strategy
JWTs are stateless — once issued, they’re valid until they expire. If a user logs out or gets compromised, you can’t invalidate their token without extra infrastructure.
Practical Revocation Strategies
// Strategy 1: Token blacklist with Redis
const redis = require('ioredis');
const client = new redis();
async function revokeToken(token, decoded) {
const ttl = decoded.exp - Math.floor(Date.now() / 1000);
if (ttl > 0) {
await client.set(`blacklist:${token}`, '1', 'EX', ttl);
}
}
async function isRevoked(token) {
return await client.exists(`blacklist:${token}`);
}
// Strategy 2: Per-user token version
// Store a tokenVersion in your DB; bump it on logout/password change
const decoded = jwt.verify(token, SECRET_KEY, { algorithms: ['HS256'] });
const user = await db.findUser(decoded.sub);
if (decoded.tokenVersion !== user.tokenVersion) {
throw new Error('Token revoked');
}Pitfall 7: Accepting Tokens from the URL
Passing JWTs as query parameters (?token=eyJ...) is dangerous — URLs get logged in server access logs, browser history, referrer headers, and proxy logs. Always send tokens in the Authorization header or secure HTTP-only cookies.
// ✅ Send in Authorization header
fetch('/api/data', {
headers: {
'Authorization': 'Bearer ' + accessToken
}
});
// ✅ Or use HTTP-only secure cookies (set by server)
// Set-Cookie: token=eyJ...; HttpOnly; Secure; SameSite=Strict; Path=/Security Checklist
Before shipping any JWT implementation, verify every item:
- Algorithm pinned server-side — never trust the token’s
algheader - Strong secret — minimum 256-bit random key for HMAC; proper key pair for RSA/ECDSA
- Short expiration — 15 minutes for access tokens, refresh tokens for longevity
- No sensitive data in payload — treat it as public
- Revocation strategy — blacklist or token versioning
- Secure transport only — HTTPS everywhere, tokens in headers or HTTP-only cookies
- Validate all claims —
iss,aud,exp,nbf - Use well-maintained libraries — don’t roll your own JWT parsing
Recommended Libraries (2026)
- Node.js:
jose(modern, spec-compliant) orjsonwebtoken(with explicit algorithm config) - Python:
PyJWT>=2.x(algorithm enforcement by default) orpython-jose - Go:
golang-jwt/jwt/v5 - Rust:
jsonwebtokencrate
Conclusion
JWTs are a powerful tool, but their simplicity is deceptive. Every pitfall above has been exploited in real-world breaches. The good news? Each fix is straightforward — pin your algorithms, use strong secrets, keep tokens short-lived, and never store secrets in the payload. Treat your JWT implementation like any other security-critical code: review it, test it, and keep your libraries updated.

Leave a Reply