JWT Security Best Practices: Common Vulnerabilities and How to Fix Them
JSON Web Tokens power authentication in millions of applications, but subtle implementation mistakes lead to critical vulnerabilities. Learn the most dangerous JWT flaws and how to eliminate them.
JSON Web Tokens (JWTs) have become the de facto standard for stateless authentication in modern web applications. Their self-contained nature makes them attractive: no server-side session storage, easy horizontal scaling, and a clean interface for API authentication. But that convenience comes with sharp edges. Misconfigured JWTs are responsible for a long list of breaches, and the mistakes are surprisingly easy to make.
This post walks through the most dangerous JWT vulnerabilities, how attackers exploit them, and the concrete steps you can take to fix each one.
Understanding JWT Structure
Before diving into vulnerabilities, a quick refresher. A JWT consists of three Base64url-encoded parts separated by dots:
header.payload.signature
The header declares the algorithm. The payload carries claims (user ID, roles, expiry). The signature proves the token hasn't been tampered with — but only if the algorithm and secret are handled correctly.
The alg:none Attack
This is one of the most notorious JWT vulnerabilities. The JWT specification allows an alg value of none, meaning an unsigned token. A number of early JWT libraries would accept tokens with "alg": "none" even when the application expected signed tokens.
Attack scenario:
- Attacker receives a valid JWT signed with HS256.
- Attacker decodes the header and payload (no secret needed — it's just Base64).
- Attacker modifies the payload (e.g.,
"role": "admin"). - Attacker re-encodes with
"alg": "none"and sends an empty signature. - Vulnerable library accepts the token.
The fix: Explicitly specify which algorithms your verification code accepts. Never allow the algorithm to be determined by the token itself.
// VULNERABLE: trusts the alg header in the token
jwt.verify(token, secret);
// SECURE: explicitly allow only the expected algorithm
jwt.verify(token, secret, { algorithms: ['HS256'] });
// For RS256/ES256 (asymmetric keys)
jwt.verify(token, publicKey, { algorithms: ['RS256'] });
Most modern libraries have patched this, but old installations and certain configurations still fall through.
Weak Signing Secrets
HS256, HS384, and HS512 use a shared secret for both signing and verification. If that secret is short, guessable, or derived from predictable values, an attacker can brute-force it offline using a captured token.
Common offenders:
secret,password,jwt_secret- The application name or domain
- Reused database passwords
- Secrets committed to version control
The fix: Generate a cryptographically random secret of at least 256 bits (32 bytes).
# Generate a secure random secret
openssl rand -base64 32
# Output: something like: K3pZ8mRqLw...
# In Node.js
node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
Store this in your environment variables, never in source code. For sensitive applications, prefer asymmetric keys (RS256 or ES256) so verification servers never need the private signing key.
Storing JWTs in localStorage vs HttpOnly Cookies
Where you store a JWT matters as much as how you generate it.
localStorage vulnerabilities: Any JavaScript on the page can read localStorage. This includes third-party scripts, browser extensions injected into the page, and XSS payloads. Once an attacker achieves XSS, they can exfiltrate every token in localStorage.
HttpOnly cookies cannot be read by JavaScript at all. The browser attaches them to requests automatically, and they're invisible to any script running on the page.
// VULNERABLE: token accessible to any script
localStorage.setItem('authToken', token);
// SECURE: HttpOnly cookie set server-side (Node.js/Express example)
res.cookie('authToken', token, {
httpOnly: true, // Not accessible via JavaScript
secure: true, // HTTPS only
sameSite: 'lax', // CSRF protection
maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days
});
The tradeoff is CSRF risk — mitigated by sameSite: 'lax' or 'strict' and additional CSRF token validation for state-changing requests.
Token Expiry and Refresh Token Rotation
JWTs are stateless, which means there's no built-in revocation mechanism. A stolen token is valid until it expires. Short-lived access tokens limit the damage window.
Recommended TTLs:
- Access tokens: 15 minutes
- Refresh tokens: 7–30 days
Refresh token rotation means issuing a new refresh token every time an access token is refreshed, and invalidating the old one. This detects token theft: if an attacker uses a refresh token, the legitimate user's next refresh attempt will fail (the token was already rotated), alerting the system.
// Refresh token rotation flow
async function refreshAccessToken(refreshToken) {
const stored = await db.findRefreshToken(refreshToken);
if (!stored || stored.used || stored.expiresAt < Date.now()) {
// If token was already used, possible theft — invalidate entire family
if (stored?.used) {
await db.invalidateTokenFamily(stored.familyId);
}
throw new Error('Invalid refresh token');
}
// Mark old token as used
await db.markRefreshTokenUsed(refreshToken);
// Issue new access + refresh token pair
const newAccessToken = generateAccessToken(stored.userId);
const newRefreshToken = await generateRefreshToken(stored.userId, stored.familyId);
return { accessToken: newAccessToken, refreshToken: newRefreshToken };
}
Leaking Sensitive Data in the Payload
JWT payloads are Base64-encoded, not encrypted. Anyone who intercepts a JWT — through logs, error messages, browser history, or network inspection — can decode and read the payload.
Never include in JWT payloads:
- Passwords or password hashes
- Social Security numbers, credit card numbers
- API keys or internal secrets
- Sensitive medical or financial data
- Internal system details an attacker could exploit
// VULNERABLE: too much information in payload
const payload = {
userId: user.id,
email: user.email,
passwordHash: user.passwordHash, // NEVER
ssn: user.ssn, // NEVER
internalRole: 'super_admin', // Reveals internal structure
dbConnectionString: '...', // Absolutely never
};
// SECURE: minimal claims needed for authorization
const payload = {
sub: user.userId, // Subject (standard claim)
role: user.role, // Only what's needed for authz decisions
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + (15 * 60), // 15 min
};
If you need to encrypt the payload (JWT with encryption is called JWE), use a library that supports it, but most applications don't need full encryption — they just need to stop putting sensitive fields in the payload.
Algorithm Confusion: HS256 vs RS256
A related attack targets applications that switch between symmetric (HS256) and asymmetric (RS256) algorithms. If a server uses RS256 to sign tokens with a private key and verifies with the corresponding public key — and that public key is publicly accessible — an attacker can:
- Obtain the public key.
- Use it as an HS256 symmetric secret to sign a forged token.
- Send the token with
"alg": "HS256". - A vulnerable verifier using the public key as an HMAC secret will accept it.
The fix is the same: always specify the allowed algorithm explicitly in your verification call, never derive it from the token header.
Token Revocation Strategies
Pure stateless JWTs can't be revoked. For high-security applications, you need a hybrid approach:
Denylist (blocklist): Maintain a Redis set of revoked token JTI (JWT ID) values. Check this on every request. This adds a network round-trip but allows immediate revocation.
// Mint token with a unique JTI
const token = jwt.sign(
{ sub: userId, jti: nanoid(), ...otherClaims },
secret,
{ expiresIn: '15m' }
);
// On verification, check the denylist
async function verifyToken(token) {
const decoded = jwt.verify(token, secret, { algorithms: ['HS256'] });
const isRevoked = await redis.sismember('revoked_tokens', decoded.jti);
if (isRevoked) throw new Error('Token has been revoked');
return decoded;
}
// On logout or security event
async function revokeToken(jti, expiresAt) {
// Only store until token natural expiry to keep denylist bounded
const ttl = Math.max(0, expiresAt - Math.floor(Date.now() / 1000));
await redis.setex(`revoked:${jti}`, ttl, '1');
}
Audit Your JWT Implementation
Beyond individual settings, audit your entire JWT lifecycle:
- Issuance: Only issue tokens after successful authentication. Include an expiry (
exp) and issuance time (iat) in every token. - Transmission: Tokens should only travel over HTTPS. Never log tokens.
- Verification: Validate algorithm, signature, expiry, issuer (
iss), and audience (aud). - Storage: HttpOnly cookies on the web; secure storage APIs on mobile.
- Revocation: Have a plan for revoking tokens when accounts are compromised.
Summary
| Vulnerability | Fix |
|---|---|
| alg:none | Specify allowed algorithms explicitly in verify call |
| Weak secret | Use 256-bit+ random secret or RS256/ES256 |
| localStorage storage | Use HttpOnly, Secure, SameSite cookies |
| Long expiry | 15-minute access tokens + refresh token rotation |
| Sensitive payload data | Never put PII or secrets in the payload |
| Algorithm confusion | Explicitly whitelist allowed algorithms |
| No revocation | Implement JTI denylist for high-value tokens |
JWTs are a powerful tool, but they require careful handling at every step. The good news is that each of these vulnerabilities has a clear, implementable fix. Run through this checklist on your current implementation and you'll eliminate the most common sources of JWT-related breaches.