JWT Best Practices: Security, Expiry, and Storage
Last updated:
JSON Web Tokens are widely misused — long-lived access tokens, credentials stored in localStorage, and libraries that trust the algorithm in the token header are the three most common vulnerabilities in JWT implementations. OWASP recommends 15-minute access token expiry, HttpOnly cookie storage, and always explicitly specifying allowed algorithms in verification calls. This guide covers every significant JWT security decision: token expiry, storage location, the alg:none attack, JWKS-based key rotation, revocation strategies, and what never to put in a payload. For the underlying token format, see how JWT works and how to decode a JWT.
Need to inspect a JWT? Paste it into Jsonic's JWT Decoder to read the header, payload, and claims instantly — without sending it to a server.
Open JWT Decoder →Token Expiry: Access Tokens vs Refresh Tokens
The single most impactful JWT security decision is expiry. A stolen access token with a 24-hour TTL gives an attacker a full day of access with no way to stop them — the token is stateless, and without a blocklist, the server cannot know the token was stolen. OWASP recommends a maximum of 15 minutes for access tokens. The exp claim is a Unix timestamp; tokens with exp in the past must be rejected unconditionally.
Refresh tokens — long-lived credentials stored server-side and used only to obtain new access tokens — should expire in 7 to 30 days depending on your session requirements. Consumer apps typically use 30-day sliding-window refresh tokens (the TTL resets on each successful use), so active users are never logged out. Enterprise applications often use fixed 7-day windows. The key distinction: access tokens are verified from the signature alone (stateless); refresh tokens are looked up in a database or Redis store on every use (stateful), which allows them to be revoked.
import jwt from 'jsonwebtoken'
import crypto from 'crypto'
const ACCESS_SECRET = process.env.ACCESS_TOKEN_SECRET! // min 32 random bytes
const REFRESH_SECRET = process.env.REFRESH_TOKEN_SECRET! // different secret
function issueTokens(userId: string) {
const accessToken = jwt.sign(
{ sub: userId, type: 'access' },
ACCESS_SECRET,
{
expiresIn: '15m', // OWASP recommended maximum
issuer: 'https://auth.example.com',
audience: 'api.example.com',
jwtid: crypto.randomUUID(), // jti — required for revocation
}
)
const refreshToken = jwt.sign(
{ sub: userId, type: 'refresh', fam: crypto.randomUUID() },
REFRESH_SECRET,
{
expiresIn: '30d', // 7–30 days depending on session policy
issuer: 'https://auth.example.com',
audience: 'https://auth.example.com/refresh', // narrower audience
jwtid: crypto.randomUUID(),
}
)
return { accessToken, refreshToken }
}
// ─── Token verification ───────────────────────────────────────────────────────
// ALWAYS specify algorithms — never let the library read alg from the header
function verifyAccessToken(token: string) {
return jwt.verify(token, ACCESS_SECRET, {
algorithms: ['HS256'], // explicit allowlist — blocks alg:none attack
issuer: 'https://auth.example.com',
audience: 'api.example.com',
})
}Note the jwtid (maps to the jti claim) in both tokens. This unique identifier is what enables revocation via a Redis blocklist — without it, you cannot distinguish one token from another on the blocklist. See the JWT claims guide for a full reference on registered claim names.
Storage: HttpOnly Cookies vs localStorage
Where you store a JWT determines your XSS and CSRF exposure. The three common options — localStorage, sessionStorage, and cookies — have very different security properties. A fourth option, JavaScript memory (a module-level variable or React state), is the right choice for short-lived access tokens in SPAs.
| Storage | XSS risk | CSRF risk | Survives page reload | Recommendation |
|---|---|---|---|---|
localStorage | High — readable by any JS | None — not auto-sent | Yes | Never use for tokens |
sessionStorage | High — readable by any JS | None — not auto-sent | No (tab-scoped) | Never use for tokens |
| HttpOnly cookie | None — JS cannot read it | Mitigated by SameSite=Strict | Yes | Use for refresh tokens |
| JS memory (variable) | Low — lost on reload | None — not auto-sent | No | Use for access tokens in SPAs |
The recommended pattern for SPAs: store the short-lived access token (15 min) in a JavaScript module-level variable. It is never persisted to disk, so it is lost on page reload — but a silent refresh call on page load re-acquires it from the HttpOnly cookie in under 100 ms. Store the refresh token exclusively in an HttpOnly cookie with three required flags.
// Secure cookie configuration — required flags for refresh tokens
res.cookie('refreshToken', refreshToken, {
httpOnly: true, // not readable by document.cookie or any JS API
secure: true, // HTTPS only — browser omits cookie on HTTP
sameSite: 'strict', // never sent on cross-site requests (blocks CSRF)
path: '/auth', // only sent to /auth/* — reduces exposure surface
maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days in milliseconds
})
// SPA: access token lives in memory only
let accessToken: string | null = null // module-level, never in localStorage
// On page load — re-acquire from the cookie via silent refresh
async function initAuth() {
const res = await fetch('/auth/refresh', {
method: 'POST',
credentials: 'include', // sends the HttpOnly cookie
})
if (res.ok) {
const data = await res.json()
accessToken = data.accessToken
}
}The path: '/auth' restriction means the browser only sends the refresh token cookie to routes under /auth/ — it is never attached to API calls, further limiting exposure. For more on localStorage patterns and their appropriate use cases, see JSON in localStorage.
Algorithm Security: Fixing the alg:none Attack
The alg:none attack exploits JWT libraries that read the algorithm from the token's own header before verifying the signature. An attacker modifies a valid token: they change "alg": "HS256" to "alg": "none" in the header, strip the signature (leaving a trailing dot), and submit the modified token. Vulnerable libraries attempt to verify using "no algorithm" — which trivially succeeds because there is no signature to validate. The attacker can now forge arbitrary payloads.
A second algorithm confusion attack targets RS256 vs HS256. If a server normally uses RS256 (asymmetric, signs with private key, verifies with public key), an attacker can submit a token with "alg": "HS256" and sign it using the server's public key as the HMAC secret. If the library blindly trusts the header's algorithm, it verifies the HS256 token against the public key — and succeeds, because the attacker knows the public key.
// ❌ VULNERABLE — trusts the algorithm from the token header
const payload = jwt.verify(token, secret) // library reads alg from header
// ✅ SECURE — always specify allowed algorithms explicitly
const payload = jwt.verify(token, secret, {
algorithms: ['HS256'], // only HS256 is accepted — 'none' is blocked
})
// ✅ SECURE — RS256 with public key
const payload = jwt.verify(token, publicKey, {
algorithms: ['RS256'], // block algorithm confusion: HS256 with public key
})
// ── Validating all standard claims ───────────────────────────────────────────
const payload = jwt.verify(token, secret, {
algorithms: ['HS256'],
issuer: 'https://auth.example.com', // rejects tokens from other issuers
audience: 'api.example.com', // rejects tokens meant for other services
clockTolerance: 30, // 30-second clock skew tolerance
}) as jwt.JwtPayload
// Also validate the jti is not blocklisted
const blocked = await redis.get(`blocklist:${payload.jti}`)
if (blocked) throw new Error('Token revoked')RFC 8725 Section 2.1 explicitly prohibits the use of the none algorithm in any context that requires integrity protection. Most modern libraries (jsonwebtoken v9+, python-jose, java-jwt) block none by default, but you must still pass an explicit algorithms list to prevent the RS256/HS256 confusion attack.
Key Rotation and JWKS Endpoints
A JWKS (JSON Web Key Set) endpoint is a public URL that returns your server's current signing public keys as a JSON array. The standard path is /.well-known/jwks.json. External services use it to verify your tokens without you sharing any private keys. Each key object includes a kid (key ID) that matches the kid in the JWT header — allowing verifiers to select the correct key when multiple keys are active.
Key rotation without invalidating active tokens requires a 4-step overlap window. Add the new key to JWKS first; start signing new tokens with it; keep accepting tokens signed by the old key until they expire; then remove the old key. The overlap duration must be at least the access token TTL (15–30 minutes for most systems).
// JWKS endpoint — GET /.well-known/jwks.json
import { exportJWK, generateKeyPair } from 'jose'
// Generate RSA key pair (do this offline; store private key in secrets manager)
const { publicKey, privateKey } = await generateKeyPair('RS256', {
modulusLength: 2048,
})
const jwk = await exportJWK(publicKey)
jwk.kid = 'key-2026-05' // versioned key ID
jwk.use = 'sig' // intended use: signature verification
jwk.alg = 'RS256'
// Serve the JWKS
app.get('/.well-known/jwks.json', (req, res) => {
res.json({
keys: [
{ ...activeJwk }, // current signing key
// { ...oldJwk }, // previous key — kept during overlap window
],
})
})
// Sign tokens with the current key ID in the header
import { SignJWT } from 'jose'
const token = await new SignJWT({ sub: userId, type: 'access' })
.setProtectedHeader({ alg: 'RS256', kid: 'key-2026-05' })
.setIssuedAt()
.setExpirationTime('15m')
.setIssuer('https://auth.example.com')
.setAudience('api.example.com')
.setJti(crypto.randomUUID())
.sign(privateKey)
// Verification — resolve key by kid from JWKS
import { createRemoteJWKSet, jwtVerify } from 'jose'
const JWKS = createRemoteJWKSet(
new URL('https://auth.example.com/.well-known/jwks.json'),
{ cacheMaxAge: 10 * 60 * 1000 } // cache JWKS for 10 minutes
)
const { payload } = await jwtVerify(token, JWKS, {
algorithms: ['RS256'],
issuer: 'https://auth.example.com',
audience: 'api.example.com',
})Consumers of your JWKS endpoint should cache the response for 5 to 15 minutes to avoid overwhelming your server. They should only refetch on a cache miss when they encounter a kid they don't recognise in a token header — this is the standard pattern described in JSON Web Key (JWK) format.
Token Revocation Strategies
JWT access tokens are stateless — there is no built-in revocation mechanism. Once issued, a valid token remains valid until its exp timestamp, regardless of whether the user has logged out, changed their password, or been compromised. Three strategies address this; the right choice depends on your latency and complexity budget.
Strategy 1: Short expiry (simplest). With a 15-minute TTL, a stolen token stops working within 15 minutes at most with no infrastructure required. This is acceptable for most applications. Revocation is immediate for logout use cases where 15 minutes of residual access is tolerable.
Strategy 2: Redis blocklist (recommended for immediate revocation). Store the jti claim of revoked tokens in Redis with a TTL equal to the token's remaining lifetime. Check the blocklist on every request in O(1) time. Storage cost is approximately 100 bytes per revoked token; 10,000 entries use about 50 KB. Redis TTL auto-expiry keeps the blocklist bounded.
Strategy 3: Version counter (for revoke-all-tokens scenarios). Store a tokenVersion integer in the user record. Embed the current version as a claim in every token. On every request, compare the token's version against the database. To invalidate all of a user's tokens (password change, account compromise), increment the counter — all existing tokens instantly become invalid because their version no longer matches.
import { createClient } from 'redis'
const redis = createClient({ url: process.env.REDIS_URL })
await redis.connect()
// ── Strategy 2: Redis blocklist ───────────────────────────────────────────────
async function revokeToken(token: string) {
const payload = jwt.decode(token) as jwt.JwtPayload
if (!payload?.jti || !payload?.exp) throw new Error('Token missing jti or exp')
const remainingTtl = payload.exp - Math.floor(Date.now() / 1000)
if (remainingTtl > 0) {
await redis.set(`blocklist:${payload.jti}`, '1', { EX: remainingTtl })
}
}
async function isRevoked(jti: string): Promise<boolean> {
const result = await redis.get(`blocklist:${jti}`)
return result !== null
}
// Middleware: verify token and check blocklist
async function requireAuth(req, res, next) {
const token = req.headers.authorization?.slice(7)
if (!token) return res.status(401).json({ error: 'Missing token' })
let payload: jwt.JwtPayload
try {
payload = jwt.verify(token, ACCESS_SECRET, {
algorithms: ['HS256'],
issuer: 'https://auth.example.com',
audience: 'api.example.com',
}) as jwt.JwtPayload
} catch {
return res.status(401).json({ error: 'Invalid token' })
}
if (await isRevoked(payload.jti!)) {
return res.status(401).json({ error: 'Token revoked' })
}
req.user = { id: payload.sub }
next()
}
// POST /auth/logout
app.post('/auth/logout', requireAuth, async (req, res) => {
const token = req.headers.authorization!.slice(7)
await revokeToken(token)
res.clearCookie('refreshToken', { path: '/auth' })
res.json({ success: true })
})Payload Security: What Not to Store
JWT payloads are base64url-encoded, not encrypted. Anyone who holds a token can decode the payload in a single function call — no secret is required. The payload is also potentially visible in server logs, browser history (if tokens appear in URLs), network proxies, and CDN access logs.
// Decode a JWT payload without any library — no secret needed
const [, payloadB64] = token.split('.')
const payload = JSON.parse(
atob(payloadB64.replace(/-/g, '+').replace(/_/g, '/'))
)
// Every token holder can do this.Never include in a JWT payload:
- Passwords or password hashes
- Social security numbers, national ID numbers
- Credit card or bank account numbers
- Medical records or health information
- Private keys or API secrets
- Full name + address + date of birth combinations (PII aggregation)
Include only what is needed for authorization decisions on the receiving service. A well-formed access token payload for a typical API looks like this:
// ✅ Minimal, safe JWT payload
{
"sub": "usr_01HX4Y...", // opaque user ID — not email or username
"iss": "https://auth.example.com",
"aud": "api.example.com",
"exp": 1747728000, // 15 minutes from issuance
"iat": 1747727100,
"jti": "550e8400-e29b-41d4-...", // unique ID for revocation
"roles": ["user"], // coarse-grained permission claims only
"org_id": "org_01HX..." // opaque org ID — not org name
}
// ❌ Never put this in a JWT payload
{
"sub": "jane.doe@company.com", // email is PII — use opaque ID instead
"password": "...", // never
"ssn": "123-45-6789", // never
"plan": "enterprise", // fine for coarse-grained access, but
"credit_card": "4111..." // never, under any circumstances
}If you need to transmit sensitive data inside a token, use JSON Web Encryption (JWE) — a separate standard that actually encrypts the payload. The JWE format is header.encrypted_key.iv.ciphertext.tag rather than the signed-only JWT's header.payload.signature.
Definitions
- Access token
- A short-lived signed JWT (15-minute maximum TTL) sent in the
Authorization: Bearerheader. Verified stateless from the signature — no database lookup required. Stored in JavaScript memory in SPAs. - Refresh token
- A long-lived credential (7–30 days) stored in an HttpOnly cookie. Used only at the
/auth/refreshendpoint to obtain new access tokens. Tracked server-side (database or Redis) to support revocation and rotation. - JWKS
- JSON Web Key Set — a JSON document published at
/.well-known/jwks.jsonlisting the public keys used to sign tokens. Enables any service to verify tokens without sharing private keys. Keys are identified by akid(key ID) claim in both the JWKS and the token header. - alg:none
- A JWT attack where an attacker sets the header's
algfield to"none"and strips the signature. Vulnerable libraries that trust the header accept the token without verification. Prevented by always passing an explicitalgorithmsallowlist to the verify function. - Token revocation
- Invalidating a JWT before its natural expiry time. Because JWTs are stateless, revocation requires external infrastructure: a Redis blocklist (keyed on the
jticlaim) or a version counter in the user record. Short access token expiry (15 min) minimises the window in which revocation is needed.
Production Checklist
Use this checklist before deploying any JWT-based authentication system. Each item maps to a concrete attack class it prevents.
- Expiry: access tokens expire in 15 minutes or less (
expiresIn: '15m'); refresh tokens expire in 7–30 days - Algorithm allowlist: pass
{ algorithms: ['HS256'] }(or['RS256']) to every verify call — never omit this option - Issuer validation: check
issmatches your expected issuer on every verification — prevents token replay from other systems - Audience validation: check
audmatches the intended service — prevents a token issued for service A from being accepted by service B - jti claim: include a unique
jti(UUID) in every token to support blocklisting and replay detection - Refresh token storage: HttpOnly + Secure + SameSite=Strict cookie with
path='/auth'— never localStorage or sessionStorage - Access token storage: JavaScript memory only — never persisted to localStorage, sessionStorage, or cookies
- Payload hygiene: no passwords, PII, or secrets in any JWT payload — payloads are base64-encoded, not encrypted
- Revocation infrastructure: Redis blocklist for immediate revocation, or confirmed acceptance that 15 minutes of residual access is tolerable on logout
- Key rotation: use asymmetric keys (RS256/ES256) with a JWKS endpoint; rotate annually or after any suspected key compromise
- HTTPS only: never issue or accept JWT-authenticated requests over plain HTTP in any environment (including staging)
- Refresh token rotation: issue a new refresh token on every
/auth/refreshcall; detect and handle reuse (theft signal)
// Production verification — all claims validated
import jwt from 'jsonwebtoken'
import { createClient } from 'redis'
const redis = createClient({ url: process.env.REDIS_URL })
await redis.connect()
export async function verifyAccessToken(token: string) {
// 1. Verify signature, algorithm, expiry, issuer, audience atomically
const payload = jwt.verify(token, process.env.ACCESS_TOKEN_SECRET!, {
algorithms: ['HS256'], // explicit allowlist
issuer: 'https://auth.example.com', // iss check
audience: 'api.example.com', // aud check
clockTolerance: 30, // 30-second clock skew tolerance
}) as jwt.JwtPayload
// 2. Check the jti blocklist (O(1) Redis GET)
if (!payload.jti) throw new Error('Token missing jti claim')
const revoked = await redis.get(`blocklist:${payload.jti}`)
if (revoked) throw new Error('Token has been revoked')
return payload // { sub, iss, aud, exp, iat, jti, roles, ... }
}Frequently asked questions
How long should a JWT access token be valid?
OWASP recommends 15 minutes or less. Short expiry limits the damage window if a token is stolen — it stops working within 15 minutes with no server action. High-security applications (banking, healthcare) use 5-minute TTLs. The trade-off is more frequent silent refreshes, which are handled automatically by a refresh token in an HttpOnly cookie. Long-lived access tokens (hours or days) are a common misconfiguration that dramatically increases breach risk because JWTs are stateless and cannot be revoked without a blocklist.
What is the alg:none attack on JWTs?
The alg:none attack exploits JWT libraries that trust the algorithm specified in the token's own header. An attacker takes a valid token, changes "alg" to "none", removes the signature, and submits it. Vulnerable libraries verify with "no algorithm" — which trivially succeeds. Fix: always pass an explicit algorithms allowlist to your verify call. This also blocks the RS256/HS256 confusion attack where an attacker signs with the server's public key and claims HS256. Described in RFC 8725 Section 2.1.
Should I store JWTs in localStorage or HttpOnly cookies?
Always use HttpOnly Secure SameSite=Strict cookies for refresh tokens. localStorage is readable by any JavaScript on the page — a single XSS vulnerability exfiltrates the token instantly. HttpOnly cookies cannot be read by JavaScript at all. SameSite=Strict blocks CSRF. The recommended SPA pattern: access token in JS memory (re-acquired via silent refresh on page load), refresh token in HttpOnly cookie.
Can I store passwords or sensitive data in a JWT payload?
No. JWT payloads are base64url-encoded, not encrypted. Any token holder can decode the payload without knowing your signing secret. Never include passwords, social security numbers, credit card data, or other PII. Include only opaque IDs and coarse-grained permission claims. For genuinely confidential payload data, use JSON Web Encryption (JWE) instead of a standard signed JWT.
How do I revoke a JWT before it expires?
Add the token's jti claim to a Redis blocklist with a TTL equal to the token's remaining lifetime. Check the blocklist on every request in O(1) time. Storage is roughly 100 bytes per entry — 10,000 revoked tokens use about 50 KB. Alternatively, use very short access token expiry (15 minutes) and accept that logout has a 15-minute residual window, which is acceptable for most applications.
What is a JWKS endpoint and why do I need one?
A JWKS endpoint (/.well-known/jwks.json) publishes your server's public signing keys so other services can verify your tokens without you sharing private keys. Each key object includes a kid (key ID) that matches the kid in the JWT header, allowing verifiers to select the correct key when multiple keys are active during a rotation. Consumers should cache JWKS responses for 5 to 15 minutes and refetch only on an unknown kid.
How do I rotate JWT signing keys without invalidating active tokens?
Use a 4-step overlap: (1) generate the new key pair and add it to JWKS; (2) start signing new tokens with the new key's kid; (3) continue accepting tokens signed by the old key until they naturally expire (wait at least the access token TTL, typically 15–30 minutes); (4) remove the old key from JWKS. The kid claim in the JWT header identifies which key to use for verification, making multi-key operation seamless.
What claims should every production JWT include?
Every production JWT should include: sub (subject — opaque user ID), iss (issuer — your auth server URL), aud (audience — the intended recipient service), exp (expiration — Unix timestamp), iat (issued at), and jti (JWT ID — unique identifier for revocation). Always validate all of these on the server: iss prevents token replay from other issuers, aud prevents token substitution across services, and jti enables blocklisting. See the JWT claims reference for the full registered claim set.
Further reading and primary sources
- OWASP JWT Security Cheat Sheet — OWASP guidance on JWT security
- RFC 8725 JWT Best Current Practices — IETF Best Current Practices for JSON Web Tokens
- JWT.io Security Guide — JWT security best practices overview
Inspect your JWT tokens
Use Jsonic's JWT Decoder to read the header, payload, and claims of any token — including the alg, kid,exp, and jti claims covered in this guide. No token leaves your browser.