Why Your Backend JWT Setup Is Insecure Despite Using a Library
March 17, 2026Your JWT library is correctly implemented. Yet your setup is insecure. I know -- that sounds provocative. You installed jsonwebtoken or PyJWT, read the docs, tokens are being generated and validated. Tests are green. Ship it. But here's the thing: the library itself is rarely the problem. The problem is what you don't configure around it. And that's exactly what I keep seeing -- both when I develop backends and when I take them apart as a pentester. In this article, I'll show you the five most common JWT mistakes I find in audits. For each mistake, you'll first get the attacker's perspective (how I exploit it) and then the fix (how to do it right). Because that's precisely the point: if you only wear the developer hat, you see a functioning token. If you put on the attacker hat, you see an attack surface.1. The alg:none Attack -- When Your Backend Accepts Any Algorithm
What the Attack Looks Like
Json
{
"alg": "HS256",
"typ": "JWT"
}
Python
import base64
import json
# Header: set algorithm to "none"
header = base64.urlsafe_b64encode(
json.dumps({"alg": "none", "typ": "JWT"}).encode()
).rstrip(b"=")
# Payload: grant ourselves admin rights
payload = base64.urlsafe_b64encode(
json.dumps({"sub": "1", "role": "admin", "exp": 9999999999}).encode()
).rstrip(b"=")
# Signature: just leave it empty
forged_token = f"{header.decode()}.{payload.decode()}."
print(forged_token)
The Fix
Javascript
const jwt = require('jsonwebtoken');
// WRONG: Algorithm is read from the token
const decoded = jwt.verify(token, secret);
// RIGHT: Algorithm is enforced server-side
const decoded = jwt.verify(token, secret, {
algorithms: ['HS256'] // ONLY accept this algorithm
});
Python
import jwt
# WRONG
decoded = jwt.decode(token, secret, algorithms=["HS256", "none"])
# RIGHT
decoded = jwt.decode(token, secret, algorithms=["HS256"])
2. Weak or Symmetric Secrets in Production
What the Attack Looks Like
Bash
# hashcat in JWT mode
hashcat -a 0 -m 16500 jwt_token.txt wordlist.txt
# Or with the specialized tool
jwt-cracker -t eyJhbGciOiJIUzI1NiIs... -d wordlist.txt
The Fix
Javascript
const crypto = require('crypto');
// Generate a cryptographically secure secret
// At least 256 bits (32 bytes) for HS256
const secret = crypto.randomBytes(64).toString('hex');
// Result: "a3f8b2c1d4e5..." (128 hex characters)
Javascript
const jwt = require('jsonwebtoken');
const fs = require('fs');
const privateKey = fs.readFileSync('./keys/private.pem');
const publicKey = fs.readFileSync('./keys/public.pem');
// Sign with private key
const token = jwt.sign({ sub: userId, role: 'user' }, privateKey, {
algorithm: 'RS256',
expiresIn: '15m'
});
// Verify with public key
const decoded = jwt.verify(token, publicKey, {
algorithms: ['RS256'] // Enforce algorithm!
});
3. Missing or Excessive Token Lifetimes
What the Attack Looks Like
Python
import jwt
import base64
import json
# Token extracted from the target application's LocalStorage
stolen_token = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0Iiwicm9sZSI6ImFkbWluIiwiZXhwIjoxNzc3NTk5NjAwfQ.xxxxx"
# When does it expire?
payload = json.loads(
base64.urlsafe_b64decode(stolen_token.split('.')[1] + '==')
)
from datetime import datetime
print(f"Expires: {datetime.fromtimestamp(payload['exp'])}")
# Output: Expires: 2026-06-28 12:00:00
# ...three months. Wonderful (for the attacker).
The Fix
Javascript
const jwt = require('jsonwebtoken');
// Access token: short lifetime (15 minutes)
const accessToken = jwt.sign(
{ sub: userId, role: userRole },
privateKey,
{
algorithm: 'RS256',
expiresIn: '15m' // 15 minutes, not 15 days
}
);
// Refresh token: longer lifetime, but stored in the DB
// and therefore revocable
const refreshToken = jwt.sign(
{ sub: userId, type: 'refresh' },
refreshPrivateKey,
{
algorithm: 'RS256',
expiresIn: '7d'
}
);
Python
import jwt
# WRONG: Expiration check disabled
decoded = jwt.decode(token, secret, algorithms=["HS256"],
options={"verify_exp": False}) # NEVER in production
# RIGHT: Keep default behavior
decoded = jwt.decode(token, secret, algorithms=["HS256"])
# Raises jwt.ExpiredSignatureError if expired
4. Issuer and Audience Are Not Validated
What the Attack Looks Like
Python
# Token for the public API (completely legitimate)
api_token_payload = {
"sub": "user-123",
"role": "user",
"iss": "public-api",
"aud": "public-api",
"exp": 1742400000
}
# The admin service doesn't check iss/aud?
# Then it accepts this token too. Privilege escalation.
The Fix
Javascript
// When creating: set issuer and audience
const token = jwt.sign(
{ sub: userId, role: 'admin' },
privateKey,
{
algorithm: 'RS256',
expiresIn: '15m',
issuer: 'admin-service',
audience: 'admin-dashboard'
}
);
// When validating: enforce issuer and audience
const decoded = jwt.verify(token, publicKey, {
algorithms: ['RS256'],
issuer: 'admin-service',
audience: 'admin-dashboard'
});
// Tokens from "public-api" are now rejected
Python
# Python equivalent
decoded = jwt.decode(
token,
public_key,
algorithms=["RS256"],
audience="admin-dashboard",
issuer="admin-service"
)
5. Sensitive Data in the JWT Payload -- Base64 Is Not Encryption
What the Attack Looks Like
Python
import base64
import json
token = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0IiwiZW1haWwiOiJhZG1pbkBjb21wYW55LmNvbSIsInBhc3N3b3JkX2hhc2giOiIkMmIkMTIkYWJjZGVmIiwiaW50ZXJuYWxfaXAiOiIxMC4wLjEuNTAiLCJkYl9yb2xlIjoic3VwZXJ1c2VyIn0.xxxxx"
# Decode payload -- no key needed!
payload_b64 = token.split('.')[1]
# Add Base64 padding
payload_b64 += '=' * (4 - len(payload_b64) % 4)
payload = json.loads(base64.urlsafe_b64decode(payload_b64))
print(json.dumps(payload, indent=2))
Json
{
"sub": "1234",
"email": "admin@company.com",
"password_hash": "$2b$12$abcdef",
"internal_ip": "10.0.1.50",
"db_role": "superuser"
}
The Fix
Javascript
// WRONG: Sensitive data in the token
const badToken = jwt.sign({
sub: userId,
email: user.email,
passwordHash: user.passwordHash, // Never!
internalIp: '10.0.1.50', // Never!
creditCard: user.creditCardLast4, // Never!
dbRole: 'superuser' // Never!
}, privateKey, { algorithm: 'RS256' });
// RIGHT: Only IDs and roles, load everything else from the DB
const goodToken = jwt.sign({
sub: userId,
role: 'admin',
jti: crypto.randomUUID() // Unique token ID for revocation
}, privateKey, {
algorithm: 'RS256',
expiresIn: '15m',
issuer: 'auth-service',
audience: 'api'
});
When You Shouldn't Use JWT at All
- You need immediate invalidation. When a user changes their password or an admin locks an account, access must end immediately. JWTs are stateless -- you can't revoke an issued token without maintaining a blacklist. And a blacklist is basically... a session database.
- Your application is a classic monolith. If you have a single server, JWT offers no advantage over server-side sessions. Sessions are simpler, more secure (no token theft from browser storage), and you get invalidation for free.
- You store JWTs in LocalStorage. LocalStorage is accessible to every JavaScript code on the page. A single XSS vulnerability -- and the attacker has the token. HttpOnly cookies are the safer choice for browser-based applications.
Javascript
// If you must use JWTs in the browser:
// HttpOnly cookie instead of LocalStorage
res.cookie('access_token', token, {
httpOnly: true, // No access via JavaScript
secure: true, // Only over HTTPS
sameSite: 'strict', // CSRF protection
maxAge: 15 * 60 * 1000 // 15 minutes
});
The Complete Checklist
- [ ] Algorithm is enforced server-side (algorithms: ['RS256'])
- [ ] alg: none is explicitly rejected
- [ ] Secret is cryptographically random and at least 256 bits long (or asymmetric keys)
- [ ] Access tokens have a short lifetime (5-15 minutes)
- [ ] Refresh tokens are stored in the database and revocable
- [ ] iss and aud claims are set and validated
- [ ] No sensitive data in the payload (no emails, hashes, internal IPs)
- [ ] Tokens are stored in HttpOnly cookies, not in LocalStorage
- [ ] HTTPS is mandatory (no tokens over unencrypted connections)
- [ ] There is a plan for key rotation
Conclusion
Want to know if your JWT setup -- or your backend in general -- can withstand a real attack? I combine backend development with offensive security and test your systems from both perspectives. Contact me for a security review or workshop: kontakt@buengener-software.de