JWT Authentication Done Right: The 2026 Security Playbook
From RS256 signing and HttpOnly cookie storage to refresh token rotation, token families, and revocation — the JWT implementation guide that actually holds up under attack.
Senior Developer

JSON Web Tokens are on every backend stack. They are also one of the most consistently misconfigured pieces of infrastructure in production systems. The appeal is obvious — stateless, portable, no session store required. The danger is equally obvious once you have seen the attack surface: algorithm confusion allows forgery, localStorage storage enables XSS theft, missing expiry validation keeps dead sessions alive indefinitely, and a compromised refresh token can persist access for weeks.
Getting JWT right is not a matter of calling a library and moving on. It requires deliberate decisions at every layer of the implementation. This guide walks through each one.
How JWT Actually Works (The Part Most Tutorials Skip)
A JWT is three Base64URL-encoded JSON objects joined by dots: header.payload.signature.
The header specifies the algorithm used to create the signature. The payload carries claims — sub (subject/user ID), iat (issued at), exp (expiry), jti (JWT ID), and any application-specific data you add. The signature is a cryptographic hash of the header and payload, signed with a secret or private key.
The signature is the only mechanism that proves the token was issued by your server and has not been tampered with. Everything else in the token is readable by anyone — JWT payloads are Base64URL-encoded, not encrypted.
The immediate implication: Never put sensitive data in a JWT payload. Passwords, SSNs, credit card numbers, sensitive PII — none of it belongs in a token that sits in a browser and travels over the network with every request.
Algorithm Selection: RS256, Not HS256
This is the decision most tutorials get wrong by defaulting to HS256.
HS256 (HMAC-SHA256) uses a single shared secret for both signing and verification. Any service that needs to verify tokens must have the same secret. In a multi-service architecture, this means every service has a key that can also create valid tokens — a significant blast radius if any service is compromised.
RS256 (RSA-SHA256) uses an asymmetric key pair: a private key to sign tokens, and a public key to verify them. Your authentication service holds the private key and is the only entity that can issue tokens. Every other service holds the public key and can verify tokens — but cannot create them.
import jwt from 'jsonwebtoken';
import { readFileSync } from 'fs';
const privateKey = readFileSync('./keys/private.pem');
const publicKey = readFileSync('./keys/public.pem');
// Signing — only in your auth service
function signAccessToken(payload) {
return jwt.sign(payload, privateKey, {
algorithm: 'RS256',
expiresIn: '15m', // Short-lived: 15 minutes
issuer: 'api.yourapp.com',
audience: 'yourapp.com',
});
}
// Verification — in every service that needs to authenticate requests
function verifyAccessToken(token) {
return jwt.verify(token, publicKey, {
algorithms: ['RS256'], // Explicitly whitelist — never omit this
issuer: 'api.yourapp.com',
audience: 'yourapp.com',
});
}The algorithms array in verify() is not optional. Omitting it leaves the door open to the algorithm confusion attack: an attacker modifies the token header to set alg: "none" or substitutes a weak algorithm, and vulnerable libraries accept the unsigned token as valid. Always explicitly specify which algorithms are acceptable.
Generating RSA keys:
# Generate 2048-bit RSA key pair (4096-bit for maximum security)
openssl genrsa -out private.pem 2048
openssl rsa -in private.pem -pubout -out public.pemStore private keys in your secrets manager (AWS Secrets Manager, HashiCorp Vault, GCP Secret Manager) — never commit them to source control, never put them in environment variables that appear in application logs.
Token Storage: The HttpOnly Cookie Decision
Where you store the access token determines your vulnerability profile.
localStorage / sessionStorage: Accessible to any JavaScript on the page. A single XSS vulnerability in any script on your domain — including third-party analytics, A/B testing libraries, or chat widgets — can steal every token stored there. In 2026, the JavaScript supply chain attack surface is significant enough that relying on localStorage security is not a defensible position.
HttpOnly cookies: Inaccessible to JavaScript. Cannot be read by XSS. The browser includes them automatically on same-origin requests. The trade-off is that they are vulnerable to CSRF (Cross-Site Request Forgery) if not properly mitigated.
The correct choice in 2026 is HttpOnly cookies with proper CSRF protection:
// Setting access token in HttpOnly cookie
function setAccessTokenCookie(res, token) {
res.cookie('access_token', token, {
httpOnly: true, // Inaccessible to JavaScript
secure: true, // HTTPS only — never send over HTTP
sameSite: 'strict', // Prevent CSRF: only sent on same-origin requests
maxAge: 15 * 60 * 1000, // 15 minutes in ms
path: '/',
});
}
// Setting refresh token in HttpOnly cookie with narrow path
function setRefreshTokenCookie(res, token) {
res.cookie('refresh_token', token, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
path: '/api/auth/refresh', // Only sent to the refresh endpoint
});
}The path: '/api/auth/refresh' on the refresh token cookie is a significant security detail. It restricts the browser to sending the refresh token cookie only to that specific path — never to your API endpoints, never to static assets, only to the token refresh endpoint. This limits the attack surface if a request to a different endpoint is intercepted.
sameSite: 'strict' prevents the browser from sending the cookie on cross-origin requests, which is your CSRF protection. If you need the cookie to work on cross-origin requests (for example, a separate API domain from your frontend), use sameSite: 'lax' and implement CSRF token validation.
The Refresh Token Architecture
Access tokens should be short-lived — 15 minutes is the standard in 2026. Short access token TTLs limit the damage from theft: a stolen token expires quickly without any server-side intervention.
The cost of short access tokens is frequent re-authentication — unless you have refresh tokens. Refresh tokens are long-lived (7–30 days), stored server-side, and used solely to issue new access tokens.
The Token Pair
import crypto from 'crypto';
// Login flow
async function login(email, password) {
const user = await validateCredentials(email, password);
if (!user) throw new Error('Invalid credentials');
// Generate opaque refresh token (not a JWT — stored in database)
const rawRefreshToken = crypto.randomBytes(64).toString('hex');
const tokenHash = crypto
.createHash('sha256')
.update(rawRefreshToken)
.digest('hex');
// Store hash (never the raw token) with metadata
await db.query(
`INSERT INTO refresh_tokens
(user_id, token_hash, family_id, expires_at, created_at)
VALUES ($1, $2, $3, NOW() + INTERVAL '7 days', NOW())`,
[user.id, tokenHash, crypto.randomUUID()]
);
// Issue short-lived JWT access token
const accessToken = signAccessToken({
sub: user.id,
email: user.email,
role: user.role,
jti: crypto.randomUUID(),
});
return { accessToken, refreshToken: rawRefreshToken };
}The refresh token is opaque (random bytes, not a JWT) and stored as a SHA-256 hash in the database. A database breach exposes hashes, not usable tokens — an attacker with the hash cannot derive the original token.
Token Rotation: Every Refresh Issues a New Pair
async function refreshTokens(rawRefreshToken) {
const tokenHash = crypto
.createHash('sha256')
.update(rawRefreshToken)
.digest('hex');
// Look up the token
const stored = await db.query(
`SELECT id, user_id, family_id, expires_at, used_at
FROM refresh_tokens
WHERE token_hash = $1`,
[tokenHash]
);
if (!stored) {
throw new UnauthorizedError('Invalid refresh token');
}
// TOKEN REUSE DETECTION: if already used, an attacker has a copy
// Revoke the entire token family immediately
if (stored.used_at !== null) {
await db.query(
'UPDATE refresh_tokens SET revoked_at = NOW() WHERE family_id = $1',
[stored.family_id]
);
throw new UnauthorizedError(
'Token reuse detected — all sessions invalidated'
);
}
// Check expiry
if (new Date(stored.expires_at) < new Date()) {
throw new UnauthorizedError('Refresh token expired');
}
// Mark as used (not deleted — needed for reuse detection)
await db.query(
'UPDATE refresh_tokens SET used_at = NOW() WHERE id = $1',
[stored.id]
);
// Issue new token pair within the same family
const newRawRefreshToken = crypto.randomBytes(64).toString('hex');
const newTokenHash = crypto
.createHash('sha256')
.update(newRawRefreshToken)
.digest('hex');
await db.query(
`INSERT INTO refresh_tokens
(user_id, token_hash, family_id, expires_at, created_at)
VALUES ($1, $2, $3, NOW() + INTERVAL '7 days', NOW())`,
[stored.user_id, newTokenHash, stored.family_id]
);
const user = await userService.getById(stored.user_id);
const newAccessToken = signAccessToken({
sub: user.id,
email: user.email,
role: user.role,
jti: crypto.randomUUID(),
});
return {
accessToken: newAccessToken,
refreshToken: newRawRefreshToken,
};
}Token families are the key to detecting theft. All refresh tokens issued from a single login share a family_id. When a refresh token is used, it is marked as used_at rather than deleted. If a rotated-out token (one with a non-null used_at) is ever presented for refresh, it means an attacker has obtained an old token. The correct response is to revoke the entire family — invalidating every active session derived from that original login.
This pattern ensures that even if a refresh token is intercepted, the attacker has a narrow window to use it. The moment the legitimate user refreshes (which happens automatically), the attacker's copy becomes invalid.
Token Revocation Strategies
The fundamental challenge with JWTs is that they are self-contained and stateless. A valid JWT stays valid until it expires, even after a user logs out or changes their password. There is no built-in revocation.
In 2026, the standard approaches are:
Strategy 1: Short-Lived Tokens (Minimal Infrastructure)
With 5–15 minute access tokens and refresh token rotation, compromised tokens expire quickly without any server-side state. The window of exposure is bounded by the access token TTL. If your threat model accepts a 15-minute window, this is the simplest approach.
Strategy 2: Redis Deny List
Maintain a set of revoked jti (JWT ID) values in Redis with TTL matching the token expiration. On every request, check the jti against the deny list.
// Add to deny list on logout or password change
async function revokeToken(jti, tokenExp) {
const ttl = tokenExp - Math.floor(Date.now() / 1000);
if (ttl > 0) {
await redis.setEx(`revoked:${jti}`, ttl, '1');
}
}
// Authentication middleware
async function authenticate(req, res, next) {
const token = extractTokenFromCookie(req);
if (!token) return res.status(401).json({ error: 'No token' });
try {
const payload = verifyAccessToken(token);
// Check deny list
const isRevoked = await redis.exists(`revoked:${payload.jti}`);
if (isRevoked) {
return res.status(401).json({ error: 'Token revoked' });
}
req.user = payload;
next();
} catch (err) {
return res.status(401).json({ error: 'Invalid token' });
}
}The Redis TTL matching the token expiry is essential — entries auto-clean when the token would have expired anyway. This keeps the deny list bounded in size.
The latency cost: Every request now requires a Redis lookup. On a local network, this adds ~1 ms. For most applications, this is acceptable. For extremely latency-sensitive systems, consider batching revocations or using a local in-process deny list with a sync interval.
Strategy 3: Token Versioning
Store a token_version counter per user in the database. Embed the version in the JWT payload at sign time. On every request, verify that the token version matches the stored version.
// Issue token with version
async function signTokenWithVersion(userId) {
const user = await db.query(
'SELECT token_version FROM users WHERE id = $1',
[userId]
);
return jwt.sign(
{ sub: userId, version: user.token_version },
privateKey,
{ algorithm: 'RS256', expiresIn: '15m' }
);
}
// Revoke all tokens by incrementing version
async function revokeAllUserTokens(userId) {
await db.query(
'UPDATE users SET token_version = token_version + 1 WHERE id = $1',
[userId]
);
}
// Verify version in middleware
async function authenticateWithVersion(req, res, next) {
const payload = verifyAccessToken(extractTokenFromCookie(req));
const user = await db.query(
'SELECT token_version FROM users WHERE id = $1',
[payload.sub]
);
if (payload.version !== user.token_version) {
return res.status(401).json({ error: 'Session invalidated' });
}
req.user = payload;
next();
}Token versioning enables instant, complete revocation for a user — useful for "log out all sessions" and "password changed" events. The cost is a database lookup on every request. Mitigate with caching: cache the token_version per user in Redis with a short TTL.
The Authentication Middleware
The central integration point for all the above:
import { expressjwt } from 'express-jwt';
import jwksClient from 'jwks-rsa';
// JWKS endpoint for public key distribution (useful in microservice architectures)
const client = jwksClient({
jwksUri: 'https://api.yourapp.com/.well-known/jwks.json',
cache: true,
cacheMaxAge: 3600000, // Cache public keys for 1 hour
});
function getKey(header, callback) {
client.getSigningKey(header.kid, (err, key) => {
callback(err, key?.getPublicKey());
});
}
// Middleware: validate JWT from HttpOnly cookie
export const authenticate = (req, res, next) => {
const token = req.cookies?.access_token;
if (!token) {
return res.status(401).json({
error: 'Authentication required',
code: 'TOKEN_MISSING',
});
}
jwt.verify(
token,
getKey,
{
algorithms: ['RS256'],
issuer: 'api.yourapp.com',
audience: 'yourapp.com',
},
async (err, payload) => {
if (err) {
const code = err.name === 'TokenExpiredError'
? 'TOKEN_EXPIRED'
: 'TOKEN_INVALID';
return res.status(401).json({ error: err.message, code });
}
// Check deny list
const revoked = await redis.exists(`revoked:${payload.jti}`);
if (revoked) {
return res.status(401).json({
error: 'Token has been revoked',
code: 'TOKEN_REVOKED',
});
}
req.user = payload;
next();
}
);
};
// Role-based access control
export const authorize = (...roles) => (req, res, next) => {
if (!roles.includes(req.user?.role)) {
return res.status(403).json({
error: 'Insufficient permissions',
code: 'FORBIDDEN',
});
}
next();
};The Database Schema
CREATE TABLE refresh_tokens (
id BIGSERIAL PRIMARY KEY,
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash CHAR(64) NOT NULL UNIQUE, -- SHA-256 of raw token
family_id UUID NOT NULL, -- All tokens from one login
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
used_at TIMESTAMPTZ, -- NULL = unused; NOT NULL = used
revoked_at TIMESTAMPTZ -- NULL = valid; NOT NULL = revoked
);
CREATE INDEX idx_refresh_tokens_user_id ON refresh_tokens (user_id);
CREATE INDEX idx_refresh_tokens_family_id ON refresh_tokens (family_id);
-- Automatic cleanup of expired tokens
CREATE INDEX idx_refresh_tokens_expires_at ON refresh_tokens (expires_at)
WHERE revoked_at IS NULL;Common Vulnerabilities and Their Fixes
alg: "none" attack: Attacker sets the algorithm header to "none" and removes the signature. Vulnerable libraries accept it. Fix: always pass algorithms: ['RS256'] to jwt.verify().
Algorithm substitution (RS256 → HS256): If a library accepts both symmetric and asymmetric algorithms and the public key is known, an attacker can sign a token with HS256 using the public key as the HMAC secret. Fix: same as above — explicitly whitelist algorithms.
JWT payload exposure: Developers assume JWT payloads are encrypted because they look like gibberish. They are just Base64. Fix: never put sensitive data in payload. Treat it as publicly readable.
Missing expiry validation: Some libraries do not validate exp by default, or it can be disabled. Fix: always validate expiry. The jsonwebtoken library validates exp by default and clockTolerance can be set for clock skew.
localStorage XSS theft: The most widespread JWT security flaw in production systems today. Fix: use HttpOnly cookies.
The Security Checklist
Before any JWT implementation reaches production:
RS256 (or ES256) — not HS256
Private key in secrets manager — not in environment variables
algorithmsarray explicitly set inverify()iss(issuer) andaud(audience) validatedAccess token TTL: 15 minutes
Refresh token: opaque, hashed in database, rotated on every use
Token families with reuse detection
HttpOnly,secure,sameSite: 'strict'cookiesRefresh token cookie scoped to
/api/auth/refreshpathRevocation via deny list or version counter
No sensitive data in JWT payload
The Authentication Decision That Matters Most
The JWT specification is not complicated. The security model is not obscure. Most authentication vulnerabilities in production are not the result of exotic attacks — they are the result of following a five-year-old tutorial that used localStorage, HS256, and a 30-day access token.
The decisions above are not over-engineering. They are the minimum viable security posture for a production system in 2026, where XSS is a known risk, supply chain attacks are real, and the consequence of authentication bypass is full account takeover.
Build the token rotation. Use the cookies. Set the TTLs. The engineering effort is a few hours. The alternative is explaining a breach.
A misconfigured JWT implementation is worse than no authentication at all. An attacker who forges or steals a token gains full access with zero server interaction."
Comments (0)
Login to post a comment.