Why JWT Security Matters So Much
JSON Web Tokens have become the de facto standard for modern web authentication: stateless, cross-domain friendly, and a natural fit for mobile. But that same flexibility means one wrong step and the entire authentication system is decorative.
The history of JWT implementations is littered with high-severity vulnerabilities from exactly this: alg: none bypasses, algorithm confusion, tokens that never expire and then leak. This guide works through five dimensions — algorithms, expiration, refresh, storage, and attack defences — and lays out the best practice for each.
1. Choosing an Algorithm
Ranked Recommendations
| Algorithm | Type | Rating | Best for |
|---|---|---|---|
| EdDSA (Ed25519) | Asymmetric | Best | New projects; fast with short keys |
| RS256 / RS384 / RS512 | Asymmetric (RSA) | Strong | Microservices, multi-service verification |
| ES256 / ES384 / ES512 | Asymmetric (ECDSA) | Strong | Mobile, performance-sensitive paths |
| HS256 / HS384 / HS512 | Symmetric (HMAC) | Acceptable | Monoliths, internal services |
| none | None | Never | Permanently disabled |
Why Asymmetric First?
Symmetric algorithms (HS256) use one key for both signing and verification. That causes three problems:
- Any service able to verify a token is also able to forge one
- In a microservice architecture the key must be distributed everywhere, widening the leak surface
- Once the key leaks, an attacker can mint a token for any identity
Asymmetric algorithms (RS256 / EdDSA) sign with a private key and verify with a public key:
- Only the authorisation server holds the private key, so only it can issue tokens
- Other services verify with the public key and cannot forge tokens even if fully compromised
- The public key can be distributed openly, for example through a JWKS endpoint
The Fatal Trap: alg: none
The JWT specification permits a header of alg: none, meaning unsigned. An attacker can simply construct:
{"alg":"none","typ":"JWT"}.{"sub":"admin","role":"superadmin"}.
Some early libraries accepted these outright. Defences:
- Use a mainstream, actively maintained library (jose, PyJWT 2.x, jjwt)
- State the expected algorithm explicitly at verification time; never trust
algfrom the header:
// Dangerous: trusts the header's alg
jwt.verify(token, secret);
// Safe: pins the algorithm
jwt.verify(token, publicKey, { algorithms: ['RS256'] });
Algorithm Confusion
The attacker rewrites an RS256 token as HS256 and re-signs it using the public key as the HMAC secret. If the server does not pin the algorithm, it will run an HMAC check with the public key — which is, by definition, public — and the attacker can forge any identity.
Defence: pin the algorithm (above), and never mix public and private key material.
Key Strength
- HS256: at least 256 bits (32 bytes) of random bytes — not a short passphrase or anything guessable
- RS256: at least 2048 bits, 3072 preferred
- EdDSA: a 255-bit key is enough, with excellent performance and security
# 32 random bytes
openssl rand -base64 32
# RSA key pair
openssl genrsa -out private.pem 2048
openssl rsa -in private.pem -pubout -out public.pem
# Ed25519 key pair
openssl genpkey -algorithm Ed25519 -out private.pem
openssl pkey -in private.pem -pubout -out public.pem
Need a strong random key rather than a passphrase? Our token generator produces one locally in your browser.
2. Expiration Policy
Core Principle: Every Token Must Expire
A JWT with no expiry is a serious security defect — once it leaks, there is no recovery. The exp claim is the first line of defence.
Recommended Lifetimes
| Token type | Recommended lifetime | Notes |
|---|---|---|
| Access Token | 15 minutes – 1 hour | Short, so the leak window is small |
| Refresh Token | 7 – 30 days | Exchanged for a new Access Token |
| ID Token (OIDC) | 5 – 10 minutes | One-shot identity handoff only |
// Issuing an Access Token
const accessToken = jwt.sign(
{ sub: userId, role: 'user' },
privateKey,
{
algorithm: 'RS256',
expiresIn: '15m', // 15 minutes
issuer: 'https://auth.example.com',
audience: 'https://api.example.com'
}
);
Why Not Just Use a Long Expiry?
Once issued, a token cannot be revoked before exp (short of maintaining a denylist, which defeats the point of statelessness). Short lifetimes plus a Refresh Token is the standard balance between security and user experience:
- The Access Token is short-lived, so a leak does limited damage
- The Refresh Token can be revoked proactively, because it is stored server-side
- The user notices nothing — the front end silently swaps in a new Access Token when the old one expires
3. Refresh Token Rotation
The Flow
Client Auth Service
│ │
│── login ────────────────────▶│
│◀─ Access Token (15m) ────────│
│ + Refresh Token (7d) ──────│
│ │
│ ... 15 minutes later ... │
│ │
│── exchange Refresh for AT ──▶│
│◀─ new Access Token ──────────│
│ + new Refresh Token ───────│ ← the old Refresh dies at once
│ │
Why Rotation Matters
Every time a Refresh Token is exchanged, issue a new Refresh Token and invalidate the old one. This gives you:
- Each Refresh Token is single-use
- If an attacker steals an already-used Refresh Token, using it trips reuse detection
- On detection, revoke that user's whole token family and force re-authentication
Implementation Notes
async function refresh(refreshToken) {
// 1. Verify the Refresh Token signature and expiry
const payload = jwt.verify(refreshToken, publicKey, {
algorithms: ['RS256']
});
// 2. Check the revocation list (needs Redis or similar)
const isRevoked = await redis.get(`revoked:${payload.jti}`);
if (isRevoked) {
// Reuse detected -> revoke the entire token family
await revokeTokenFamily(payload.family_id);
throw new Error('Token reuse detected');
}
// 3. Add the current Refresh Token to the revocation list
await redis.setex(`revoked:${payload.jti}`, payload.exp - now, '1');
// 4. Issue a new Access Token and Refresh Token
const newAccess = signAccessToken(payload.sub);
const newRefresh = signRefreshToken(payload.sub, payload.family_id);
return { accessToken: newAccess, refreshToken: newRefresh };
}
Proactive Revocation
Although JWTs themselves are stateless, Refresh Tokens must be revocable server-side:
- Denylist: on logout, password change, or anomaly detection, write the Refresh Token's
jtito Redis - Allowlist: maintain the set of valid Refresh Tokens per user and delete on logout
- The denylist is generally recommended: less storage, better performance
4. Where to Store Tokens
This is where front ends most often get it wrong. Three options compared:
Option A: localStorage (not recommended)
// Dangerous
localStorage.setItem('token', accessToken);
The problem: localStorage is readable by any same-origin JavaScript, so a single XSS vulnerability lets an attacker lift the token outright. Never put tokens in localStorage.
Option B: HttpOnly Cookie (recommended)
The server writes the token via Set-Cookie with HttpOnly + Secure + SameSite:
Set-Cookie: access_token=eyJ...; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=900
Advantages:
- JavaScript cannot read it (
document.cookiereturns nothing), so XSS token theft is blocked by construction - The browser attaches it automatically; no front-end wiring needed
Caveat: you must pair this with CSRF protection (below).
Option C: In Memory (pairs with Option B)
// Access Token held in memory
let accessToken = null;
// After a page reload, fetch a new one using the Refresh Token (HttpOnly cookie)
Advantage: the token vanishes on reload, so the attack window is tiny. Disadvantage: every reload costs an extra exchange request.
Recommended Combination
| Token | Storage |
|---|---|
| Access Token | Memory (a JavaScript variable) |
| Refresh Token | HttpOnly + Secure + SameSite=Strict cookie |
With this split, XSS can only reach a short-lived Access Token and never the Refresh Token, while SameSite cookies stop CSRF.
Mobile Apps
Native apps are not covered by the browser's same-origin policy, so:
- Store tokens in the platform secure storage: iOS Keychain / Android Keystore
- Protect the Refresh Token with biometric unlock
- Never use UserDefaults or SharedPreferences — those are plaintext
5. XSS and CSRF Defences
XSS
Cross-site scripting is the single biggest threat to JWTs: injected script reads the token and ships it off.
Layers of defence:
- Output encoding: escape all user input rendered into HTML (React and Vue escape by default)
- Content Security Policy: restrict where scripts may come from
Content-Security-Policy: default-src 'self'; script-src 'self' https://cdn.example.com; object-src 'none'
- HttpOnly cookies: keep the token away from JavaScript (above)
- Trusted Types: a modern browser API that enforces sanitised input at the DOM sink
For a full treatment of XSS — including the correct encoding for each output context, CSP nonces, and Trusted Types — see our XSS prevention guide.
CSRF
Cross-site request forgery exploits the browser's automatic cookie attachment to make a logged-in user issue a request they never intended.
Note: CSRF only threatens cookie-based schemes; a token sent manually in the Authorization header is unaffected. But the XSS advantage of cookies is too large to give up, so pair them with CSRF defences:
- SameSite cookies (first choice)
Set-Cookie: ...; SameSite=Strict # never sent cross-site
Set-Cookie: ...; SameSite=Lax # allows top-level GET navigation (the default)
Strict is safest but hurts the experience of arriving from an external link. Lax is a reasonable middle ground.
- CSRF token
The server generates a random token, embeds it in the page, and validates it on submission:
<meta name="csrf-token" content="abc123">
fetch('/api/data', {
headers: { 'X-CSRF-Token': document.querySelector('meta[name=csrf-token]').content }
});
- Double-submit cookie: keep a CSRF token in a cookie and send the same value in a request header; the server compares them.
6. Other Security Essentials
1. Minimise Claims
Put only what is necessary in the payload and never sensitive data (passwords, national ID numbers, payment details). A JWT payload is Base64-encoded, not encrypted — anyone can decode it.
// Dangerous
{ sub: '123', password: 'abc123', creditCard: '4111...' }
// Safe
{ sub: '123', role: 'user' }
You can decode any token safely in your browser with our JWT parser — nothing is uploaded.
2. Verify Every Claim
Do not stop at the signature; check all the standard claims:
jwt.verify(token, publicKey, {
algorithms: ['RS256'],
issuer: 'https://auth.example.com', // check the issuer
audience: 'https://api.example.com', // check the audience
clockTimestamp: Date.now() / 1000 // guard against clock skew
});
// The library checks exp, nbf, and iat for you
3. Use jti Against Replay
Give each token a unique ID (jti) and record the ones already consumed for sensitive operations, so a captured request cannot be replayed.
4. Rotate Signing Keys
Replace signing keys periodically (every 90 days, say), accepting both old and new during the transition:
function verify(token) {
// Try the new key
try { return jwt.verify(token, newKey, { algorithms: ['RS256'] }); }
catch { /* fallthrough */ }
// Fall back to the old key
return jwt.verify(token, oldKey, { algorithms: ['RS256'] });
}
Exposing a public key set through a JWKS endpoint and matching on the kid header makes rotation clean.
5. Redact Tokens From Logs
Never log a full token:
// Wrong
console.log(`Auth: ${token}`);
// Right
console.log(`Auth: jti=${payload.jti}, sub=${payload.sub}`);
Quick Reference
| Dimension | Best practice |
|---|---|
| Algorithm | EdDSA or RS256, pinned explicitly, none disabled |
| Keys | Prefer asymmetric, long enough, rotated regularly |
| Expiration | Access Token 15 minutes, Refresh Token 7–30 days |
| Refresh | Refresh Token rotation plus reuse detection |
| Revocation | Refresh Token denylist (Redis) |
| Storage (web) | Access Token in memory, Refresh Token in an HttpOnly cookie |
| Storage (app) | Keychain / Keystore plus biometrics |
| XSS defence | HttpOnly cookies + CSP + output encoding |
| CSRF defence | SameSite cookies + CSRF token |
| Payload | Minimal, no sensitive data |
| Verification | Check signature + exp + iss + aud + nbf |
For a side-by-side comparison of signing algorithm strength, see the hash and encryption algorithm cheat sheet; for cookie security attributes, see the HTTP headers cheat sheet.