JWT Authentication Best Practices 2026: Secure Your API Tokens
Complete JWT security guide for 2026. Covers RS256 vs HS256 signing, token storage strategies, expiry and refresh token patterns, revocation, common vulnerabilities, and a production security checklist.
JSON Web Tokens (JWTs) are everywhere. Nearly every modern API uses them for authentication, and nearly every team gets at least one thing wrong. A misconfigured JWT setup can expose your entire user base to account takeover, privilege escalation, or token forgery attacks.
This guide covers JWT best practices in 2026 β from algorithm choice to storage strategies to revocation patterns β with concrete implementation guidance and a security checklist you can use today.
What Is a JWT (Quick Recap)
A JWT is a URL-safe, base64-encoded string with three parts separated by dots:
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9 β Header
.eyJzdWIiOiJ1c2VyXzEyMyIsInJvbGUiOiJhZG1pbiIsImlhdCI6MTcxMjA1OTIwMCwiZXhwIjoxNzEyMDYyODAwfQ== β Payload
.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c β Signature
Header declares algorithm and token type. Payload contains claims (user ID, roles, expiry). Signature verifies the token hasnβt been tampered with.
Critical mental model: JWT payloads are base64-encoded, not encrypted. Anyone who holds a JWT can decode and read its contents without the secret. Never put sensitive data (passwords, SSNs, credit card numbers) in a JWT payload.
Algorithm Choice: RS256 vs HS256
This is the most consequential decision in your JWT setup.
HS256 β HMAC with SHA-256
HS256 uses a single shared secret to both sign and verify tokens.
// Signing (server)
const token = jwt.sign(payload, process.env.JWT_SECRET, { algorithm: 'HS256' });
// Verifying (same server, or any server with the same secret)
const decoded = jwt.verify(token, process.env.JWT_SECRET);
Risks:
- Any service that can verify tokens can also forge them (they hold the same secret)
- Secret rotation requires coordinated deployment across all services
- Secret leaks β all tokens ever issued are compromised
RS256 β RSA Signature with SHA-256
RS256 uses a private key to sign and a public key to verify.
// Signing (auth service only β has private key)
const token = jwt.sign(payload, privateKey, { algorithm: 'RS256' });
// Verifying (any service β only needs public key)
const decoded = jwt.verify(token, publicKey);
Advantages:
- Microservices verify tokens without being able to forge them
- Private key never leaves the auth service
- Public key can be distributed freely (or fetched via JWKS endpoint)
- Industry standard for federated auth (OAuth 2.0, OIDC)
ES256 β ECDSA with P-256
ES256 is the modern alternative to RS256. Shorter keys, same security, faster verification:
const token = jwt.sign(payload, ecPrivateKey, { algorithm: 'ES256' });
When to use which
| Scenario | Recommended Algorithm |
|---|---|
| Monolith / single service | HS256 (acceptable) |
| Microservices architecture | RS256 or ES256 |
| Third-party / federated auth | RS256 or ES256 |
| High-performance APIs | ES256 |
| Legacy systems | HS256 (if canβt change) |
Rule: Default to RS256 or ES256. HS256 is only acceptable for simple single-service setups where the secret never leaves one codebase.
Token Storage: Memory vs localStorage vs Cookies
Where you store a JWT on the client is a major security decision with real tradeoffs.
Option 1: Memory (JavaScript Variable)
// Store in module-level variable or React state
let accessToken = null;
async function login(credentials) {
const res = await fetch('/api/auth/login', { method: 'POST', body: JSON.stringify(credentials) });
const { accessToken: token } = await res.json();
accessToken = token;
}
async function apiCall(endpoint) {
return fetch(endpoint, {
headers: { Authorization: `Bearer ${accessToken}` }
});
}
Pros: Immune to XSS attacks β JavaScript from other origins canβt steal what lives in your module scope. No persistent attack surface.
Cons: Token lost on page refresh. Requires refresh token flow to restore sessions.
Best for: SPAs with sensitive data, financial apps, healthcare.
Option 2: localStorage
localStorage.setItem('token', accessToken);
const token = localStorage.getItem('token');
Pros: Persists across page refreshes, easy to implement.
Cons: Vulnerable to XSS. Any malicious JavaScript on your page can steal the token. document.cookie isnβt accessible to other origins, but localStorage is accessible to all same-origin scripts β including injected scripts.
Recommendation: Avoid for production apps. If you use it, ensure rock-solid Content Security Policy headers to minimize XSS surface.
Option 3: HttpOnly Cookie (Recommended)
// Server sets the cookie on login β browser handles storage automatically
res.cookie('access_token', token, {
httpOnly: true, // JavaScript cannot access this cookie at all
secure: true, // Only sent over HTTPS
sameSite: 'strict', // Not sent on cross-site requests (CSRF protection)
maxAge: 15 * 60 * 1000 // 15 minutes in ms
});
// Client β just make requests, cookie is sent automatically
fetch('/api/protected', { credentials: 'include' });
Pros: JavaScript (including XSS payloads) cannot read HttpOnly cookies. Combined with sameSite: strict, provides CSRF protection. Automatic expiry.
Cons: CORS configuration required for cross-origin APIs. Requires credentials: include on fetch calls.
Best for: Most production web applications. This is the industry recommendation in 2026.
Storage Comparison
| Storage | XSS Risk | CSRF Risk | Persistence | Implementation |
|---|---|---|---|---|
| Memory | None | None | Lost on refresh | Moderate |
| localStorage | High | None | Yes | Simple |
| sessionStorage | High | None | Session only | Simple |
| HttpOnly Cookie | None | Low (with sameSite) | Configurable | Moderate |
Expiry and Refresh Token Patterns
Short-Lived Access Tokens
Access tokens should expire quickly β 15 minutes is a common production value:
// Issue short-lived access token
const accessToken = jwt.sign(
{ sub: user.id, role: user.role },
privateKey,
{
algorithm: 'RS256',
expiresIn: '15m',
issuer: 'https://auth.example.com',
audience: 'https://api.example.com'
}
);
Short expiry limits the damage window if a token is stolen. A 15-minute window means a stolen token is only useful for 15 minutes.
Refresh Token Pattern
Refresh tokens are long-lived (7β30 days) and stored securely. Theyβre used only to obtain new access tokens β never for API authorization directly.
// Login flow
async function login(email, password) {
const user = await validateCredentials(email, password);
// Short-lived access token
const accessToken = jwt.sign(
{ sub: user.id, role: user.role },
privateKey,
{ algorithm: 'RS256', expiresIn: '15m' }
);
// Long-lived refresh token β store reference in DB
const refreshToken = crypto.randomBytes(32).toString('hex');
await db.refreshTokens.create({
token: hash(refreshToken), // Store hash, not plaintext
userId: user.id,
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000)
});
// Send access token in response body, refresh token as HttpOnly cookie
res.cookie('refresh_token', refreshToken, { httpOnly: true, secure: true });
res.json({ accessToken });
}
// Refresh flow
async function refreshAccessToken(req, res) {
const refreshToken = req.cookies.refresh_token;
if (!refreshToken) return res.status(401).json({ error: 'No refresh token' });
// Validate against DB
const stored = await db.refreshTokens.findOne({ token: hash(refreshToken) });
if (!stored || stored.expiresAt < new Date()) {
return res.status(401).json({ error: 'Refresh token invalid or expired' });
}
// Issue new access token
const user = await db.users.findById(stored.userId);
const accessToken = jwt.sign(
{ sub: user.id, role: user.role },
privateKey,
{ algorithm: 'RS256', expiresIn: '15m' }
);
res.json({ accessToken });
}
Silent Refresh (Client-Side)
// Automatically refresh access token before it expires
let refreshTimer;
function scheduleRefresh(token) {
const decoded = parseJwt(token);
const expiresIn = (decoded.exp * 1000) - Date.now();
const refreshAt = expiresIn - 60_000; // Refresh 1 minute before expiry
clearTimeout(refreshTimer);
refreshTimer = setTimeout(async () => {
const res = await fetch('/api/auth/refresh', { credentials: 'include' });
const { accessToken } = await res.json();
setAccessToken(accessToken);
scheduleRefresh(accessToken);
}, refreshAt);
}
Revocation Strategies
JWTs are stateless β by design, thereβs no built-in way to revoke them before expiry. This is a fundamental limitation you need to work around.
Strategy 1: Short Expiry (Simplest)
Keep access tokens to 15 minutes. A revoked token becomes useless within 15 minutes without any server-side tracking. This works for most logout scenarios.
Limitation: If an attacker steals a token, they have 15 minutes to use it.
Strategy 2: Token Blocklist (for Logout)
Maintain a Redis set of revoked token IDs:
// Add to blocklist on logout
async function logout(req, res) {
const token = extractToken(req);
const decoded = jwt.decode(token);
// Store the JTI (JWT ID) in Redis until the token expires
const ttl = decoded.exp - Math.floor(Date.now() / 1000);
await redis.setex(`blocklist:${decoded.jti}`, ttl, '1');
res.clearCookie('refresh_token');
res.json({ ok: true });
}
// Check blocklist on every request
async function verifyToken(token) {
const decoded = jwt.verify(token, publicKey);
const blocked = await redis.get(`blocklist:${decoded.jti}`);
if (blocked) throw new Error('Token revoked');
return decoded;
}
Include a jti (JWT ID) claim when issuing tokens:
const token = jwt.sign(
{ sub: user.id, jti: crypto.randomUUID() },
privateKey,
{ algorithm: 'RS256', expiresIn: '15m' }
);
Strategy 3: Version-Based Revocation
Store a tokenVersion counter per user in your database. Increment it to invalidate all tokens for that user:
// Include version in token
const token = jwt.sign(
{ sub: user.id, version: user.tokenVersion },
privateKey,
{ algorithm: 'RS256', expiresIn: '15m' }
);
// Verify version on each request
async function verifyToken(token) {
const decoded = jwt.verify(token, publicKey);
const user = await db.users.findById(decoded.sub);
if (decoded.version !== user.tokenVersion) {
throw new Error('Token version mismatch β token revoked');
}
return decoded;
}
// Revoke all tokens for a user (password change, suspicious activity)
async function revokeAllTokens(userId) {
await db.users.increment({ tokenVersion: 1 }, { where: { id: userId } });
}
Common Vulnerabilities
1. Algorithm Confusion (alg: none)
Early JWT libraries trusted the alg field in the token header. An attacker could forge tokens by setting alg: none and omitting the signature.
// VULNERABLE β trusts the header's algorithm claim
jwt.verify(token, secret); // Some old libraries accept alg:none here
// SECURE β explicitly specify expected algorithm
jwt.verify(token, publicKey, { algorithms: ['RS256'] });
Fix: Always pass algorithms option to your JWT library. Never allow none or symmetric algorithms when you expect asymmetric.
2. Weak HS256 Secrets
// INSECURE β short, guessable secret
const token = jwt.sign(payload, 'secret123', { algorithm: 'HS256' });
// SECURE β cryptographically random, 256-bit minimum
const token = jwt.sign(payload, process.env.JWT_SECRET, { algorithm: 'HS256' });
// JWT_SECRET should be: openssl rand -base64 32
HS256 secrets can be brute-forced offline if an attacker gets a sample token. Use at minimum 32 bytes of random data. Generate with:
openssl rand -base64 32
3. Missing exp Claim
// INSECURE β token never expires
const token = jwt.sign({ sub: user.id }, secret);
// SECURE β always set expiry
const token = jwt.sign({ sub: user.id }, secret, { expiresIn: '15m' });
Always set expiresIn. A non-expiring token is a permanent credential.
4. Sensitive Data in Payload
// INSECURE β don't include sensitive data
const token = jwt.sign({
sub: user.id,
email: user.email,
password: user.password, // NEVER
creditCard: user.creditCard, // NEVER
}, secret);
// SECURE β only include what's needed for authorization
const token = jwt.sign({
sub: user.id,
role: user.role,
scope: ['read:posts', 'write:posts']
}, privateKey, { algorithm: 'RS256' });
5. Not Validating iss and aud
// INSECURE β doesn't check who issued the token or who it's for
jwt.verify(token, publicKey, { algorithms: ['RS256'] });
// SECURE β validate issuer and audience
jwt.verify(token, publicKey, {
algorithms: ['RS256'],
issuer: 'https://auth.example.com',
audience: 'https://api.example.com'
});
Without iss/aud validation, tokens from one service can be replayed against another.
JWT Security Checklist
Use this checklist before going to production:
Algorithm & Keys
- Using RS256 or ES256 (not HS256 for multi-service)
- Algorithm explicitly specified in
jwt.verify()options -
alg: nonerejected - Private key stored as environment variable, not in code
- HS256 secret is at least 32 random bytes (if used)
Token Design
-
expclaim always set (β€ 15 minutes for access tokens) -
iss(issuer) claim set and validated -
aud(audience) claim set and validated -
jti(JWT ID) included for revocation support - No sensitive data (passwords, PII) in payload
Storage
- Access tokens stored in memory or HttpOnly cookie (not localStorage)
- Refresh tokens stored in HttpOnly, Secure, SameSite=Strict cookie
- Refresh tokens stored as hashed values in database
Revocation
- Logout invalidates refresh token in database
- Token blocklist or version-based revocation implemented
- Password change increments token version
Transport
- HTTPS enforced for all endpoints
- HSTS header set
- Tokens never logged in plaintext
Inspect and Debug JWTs
When debugging JWT issues in development and production:
- JWT Decoder Inspector β paste any JWT to decode all three parts, inspect claims, check expiry, and verify signature validity. Shows exactly what your tokens contain.
- JWT Decoder β quick decoder for fast header/payload inspection without signature verification.
Both tools run entirely in-browser β your tokens never leave your machine.
Summary
JWT security in 2026 comes down to five core decisions:
- Algorithm: Use RS256/ES256, never trust
algin the token header - Storage: HttpOnly cookies for web apps, memory for sensitive SPAs
- Expiry: 15-minute access tokens + refresh token rotation
- Revocation: Blocklist (for logout) or version-based (for mass revocation)
- Validation: Always check
exp,iss,aud, and algorithm
Get these five right and your JWT implementation will be significantly more secure than the average production system. The OWASP API Security Top 10 consistently includes broken authentication β most of those incidents trace back to one of the gotchas covered in this guide.
50 CLI Commands That Save Hours Every Week
The exact Git shortcuts, file search tricks, Docker commands, and productivity hacks that senior developers use daily. Compiled from 5+ years of shipping production code.
- Git mastery (10 cmds)
- File & search wizardry
- Docker & DevOps tricks
- Process & network tools
- Productivity boosters
- macOS & Linux install guide
No spam. 1 email/week max. Unsubscribe anytime.
Found this guide useful? Check out our free developer tools.
Affiliate disclosure: Some links below are affiliate links β we may earn a small commission at no extra cost to you. Learn more.
Recommended Tools & Resources
DigitalOcean
$200 credit for new users. Simple, affordable cloud hosting for developers.
GitHub Student Pack
Free access to 100+ developer tools. Perfect for students and new devs.
Vercel
Deploy frontend apps instantly. Free tier is generous for side projects.
DevPlaybook Products
Boilerplates, scripts & AI toolkits to 10x your dev workflow.