JWT Authentication Done Right: Security Mistakes I Made So You Don't Have To
Design secure JWT authentication with access/refresh tokens, httpOnly cookies, token revocation, and OAuth 2.0 integration.
I shipped a JWT implementation to production that stored access tokens in localStorage, used symmetric signing with a weak secret, and had no token revocation mechanism. For eight months, it worked fine. Then a security audit found a reflected XSS vulnerability on our marketing pages, and the auditor demonstrated how a single malicious script could steal every user’s access token from localStorage and send it to an attacker’s server. No expiration, no revocation — those tokens would work until we rotated the signing secret, which would log out every user simultaneously.
That audit report was the most educational document I have ever read. It taught me that JWT authentication has a deceptively small API surface but an enormous security surface. Getting the happy path right is easy. Getting the security right requires understanding attack vectors that most tutorials never mention.
This guide covers the JWT authentication architecture I use today — refined through that security incident and several years of production experience.
TL;DR — JWT Security Checklist
| Practice | Priority | Status in Most Tutorials |
|---|---|---|
| Store tokens in httpOnly cookies | Must-have | Rarely mentioned |
| Use RS256 (asymmetric) signing | Must-have | Usually HS256 |
| Short access token TTL (15 min) | Must-have | Often 24h+ |
| Implement refresh token rotation | Must-have | Often skipped |
| Token revocation mechanism | Must-have | Almost never covered |
| CSRF protection with SameSite cookies | Must-have | Rarely mentioned |
| Rate limit authentication endpoints | Must-have | Often forgotten |
| Validate all claims on every request | Must-have | Sometimes partial |
How JWT Authentication Works
A JSON Web Token is a self-contained, signed token that carries user identity claims. The server issues it after successful authentication, and the client sends it with every subsequent request. The server validates the signature without hitting a database — this is JWT’s primary advantage over session-based authentication.
Token Structure
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9. ← Header (algorithm, type)
eyJzdWIiOiJ1c2VyXzEyMyIsInJvbGUiOiJhZG1pbiJ9. ← Payload (claims)
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c ← Signature
The header and payload are Base64-encoded JSON — not encrypted. Anyone with the token can read the payload. The signature ensures the payload has not been tampered with. This distinction matters: never put sensitive data in the JWT payload.
// What goes in the payload
{
"sub": "user_123", // Subject (user ID)
"role": "admin", // Authorization claim
"iat": 1712764800, // Issued at
"exp": 1712765700 // Expires at (15 minutes later)
}
// What should NEVER go in the payload
{
"email": "[email protected]", // PII
"password_hash": "...", // Obviously bad
"credit_card": "...", // Obviously bad
"internal_user_id": "db_row_42" // Leaks internal structure
}
The Access Token + Refresh Token Pattern
This is the architecture I use for every production authentication system. It balances security (short-lived access) with user experience (seamless token renewal).
How It Works
1. User logs in with credentials
2. Server issues:
- Access token (short-lived, 15 minutes)
- Refresh token (long-lived, 7 days)
3. Client sends access token with every API request
4. When access token expires, client uses refresh token to get a new pair
5. When refresh token expires, user must log in again
Implementation
const jwt = require('jsonwebtoken');
const crypto = require('crypto');
const ACCESS_TOKEN_SECRET = fs.readFileSync('private.pem');
const ACCESS_TOKEN_PUBLIC = fs.readFileSync('public.pem');
function generateTokens(user) {
const accessToken = jwt.sign(
{ sub: user.id, role: user.role },
ACCESS_TOKEN_SECRET,
{ algorithm: 'RS256', expiresIn: '15m' }
);
const refreshToken = crypto.randomBytes(64).toString('hex');
return { accessToken, refreshToken };
}
// Login endpoint
app.post('/api/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, refreshToken } = generateTokens(user);
// Store refresh token in database (hashed)
await db.query(
`INSERT INTO refresh_tokens (token_hash, user_id, expires_at)
VALUES ($1, $2, NOW() + INTERVAL '7 days')`,
[hashToken(refreshToken), user.id]
);
// Set tokens in httpOnly cookies
res.cookie('access_token', accessToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 15 * 60 * 1000,
});
res.cookie('refresh_token', refreshToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
path: '/api/auth/refresh',
maxAge: 7 * 24 * 60 * 60 * 1000,
});
res.json({ user: { id: user.id, name: user.name, role: user.role } });
});
Why Short Access Token Expiration Matters
A 15-minute access token limits the damage window if a token is compromised. The attacker has at most 15 minutes to use it before it expires. Compare this with a 24-hour token — that is a 24-hour window for exploitation.
The tradeoff is more frequent token refreshes. But refresh happens automatically in the background, so users never notice. I set the frontend to refresh the access token at the 12-minute mark — 3 minutes before expiration — to avoid race conditions.
Token Storage: Why httpOnly Cookies Win
This is where my original implementation went wrong. Storing tokens in localStorage is convenient for developers but terrible for security.
The Attack Surface
| Storage Method | XSS Vulnerable | CSRF Vulnerable | Recommendation |
|---|---|---|---|
| localStorage | Yes (JavaScript accessible) | No | Never use for tokens |
| sessionStorage | Yes (JavaScript accessible) | No | Never use for tokens |
| httpOnly Cookie | No (not JavaScript accessible) | Yes (mitigated with SameSite) | Recommended |
| In-memory variable | Yes (but harder to exploit) | No | Acceptable for SPAs with refresh flow |
When a token is in localStorage, any JavaScript running on your page can read it. A single XSS vulnerability — even in a third-party script — can steal every user’s token. With httpOnly cookies, JavaScript cannot access the token at all. The browser sends it automatically with every request, and it is invisible to client-side code.
CSRF Protection with SameSite Cookies
httpOnly cookies introduce a different risk: CSRF (Cross-Site Request Forgery). A malicious site can trigger requests to your API, and the browser will include the cookies automatically. The SameSite=Strict attribute prevents this by only sending cookies on same-origin requests.
res.cookie('access_token', token, {
httpOnly: true, // Not accessible via JavaScript
secure: true, // Only sent over HTTPS
sameSite: 'strict', // Not sent on cross-origin requests
maxAge: 900000, // 15 minutes
});
For APIs that need cross-origin cookie support (e.g., API on api.example.com, frontend on www.example.com), use sameSite: 'lax' with an additional CSRF token:
// Generate CSRF token and set as a regular cookie (readable by JS)
const csrfToken = crypto.randomBytes(32).toString('hex');
res.cookie('csrf_token', csrfToken, {
httpOnly: false, // JavaScript needs to read this
secure: true,
sameSite: 'lax',
});
// Verify CSRF token on state-changing requests
function csrfProtection(req, res, next) {
if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method)) {
const headerToken = req.headers['x-csrf-token'];
const cookieToken = req.cookies.csrf_token;
if (!headerToken || headerToken !== cookieToken) {
return res.status(403).json({ error: 'Invalid CSRF token' });
}
}
next();
}
Refresh Token Rotation
Refresh token rotation issues a new refresh token every time the old one is used. This limits the window for refresh token theft and enables detection of token reuse.
app.post('/api/auth/refresh', async (req, res) => {
const oldRefreshToken = req.cookies.refresh_token;
if (!oldRefreshToken) {
return res.status(401).json({ error: 'No refresh token' });
}
const tokenHash = hashToken(oldRefreshToken);
const stored = await db.query(
'SELECT * FROM refresh_tokens WHERE token_hash = $1 AND expires_at > NOW()',
[tokenHash]
);
if (stored.rows.length === 0) {
// Token not found — possible token reuse attack
// Invalidate ALL refresh tokens for this user family
await db.query(
'DELETE FROM refresh_tokens WHERE family_id = $1',
[stored.rows[0]?.family_id]
);
return res.status(401).json({ error: 'Invalid refresh token' });
}
const tokenRecord = stored.rows[0];
// Delete the used refresh token
await db.query('DELETE FROM refresh_tokens WHERE id = $1', [tokenRecord.id]);
// Generate new token pair
const user = await db.query('SELECT * FROM users WHERE id = $1', [tokenRecord.user_id]);
const { accessToken, refreshToken: newRefreshToken } = generateTokens(user.rows[0]);
// Store new refresh token with same family ID
await db.query(
`INSERT INTO refresh_tokens (token_hash, user_id, family_id, expires_at)
VALUES ($1, $2, $3, NOW() + INTERVAL '7 days')`,
[hashToken(newRefreshToken), tokenRecord.user_id, tokenRecord.family_id]
);
res.cookie('access_token', accessToken, {
httpOnly: true, secure: true, sameSite: 'strict', maxAge: 900000,
});
res.cookie('refresh_token', newRefreshToken, {
httpOnly: true, secure: true, sameSite: 'strict',
path: '/api/auth/refresh', maxAge: 7 * 24 * 60 * 60 * 1000,
});
res.json({ success: true });
});
The family_id groups all refresh tokens from a single login session. If a used refresh token is presented again (indicating it was stolen before rotation), we invalidate the entire family — forcing the legitimate user and the attacker to re-authenticate. The legitimate user notices and resets their password. The attacker loses access.
Token Revocation: The Missing Piece
JWTs are stateless by design — the server does not track issued tokens. This means you cannot “log out” a user by invalidating their token. The token remains valid until it expires. For a 15-minute access token, this is usually acceptable. For longer-lived tokens or immediate revocation needs (account compromise, permission changes), you need a revocation mechanism.
Token Blacklist with Redis
async function revokeToken(token) {
const decoded = jwt.decode(token);
const ttl = decoded.exp - Math.floor(Date.now() / 1000);
if (ttl > 0) {
await redis.set(`blacklist:${decoded.jti}`, '1', 'EX', ttl);
}
}
function authMiddleware(req, res, next) {
const token = req.cookies.access_token;
try {
const decoded = jwt.verify(token, ACCESS_TOKEN_PUBLIC, { algorithms: ['RS256'] });
// Check blacklist
const isRevoked = await redis.get(`blacklist:${decoded.jti}`);
if (isRevoked) {
return res.status(401).json({ error: 'Token revoked' });
}
req.user = decoded;
next();
} catch (err) {
res.status(401).json({ error: 'Invalid token' });
}
}
The blacklist only needs to store tokens until their natural expiration. With 15-minute access tokens, the blacklist stays small and automatically cleans itself through TTL expiration.
Logout Implementation
app.post('/api/auth/logout', authMiddleware, async (req, res) => {
// Revoke access token
await revokeToken(req.cookies.access_token);
// Delete refresh token from database
const refreshToken = req.cookies.refresh_token;
if (refreshToken) {
await db.query(
'DELETE FROM refresh_tokens WHERE token_hash = $1',
[hashToken(refreshToken)]
);
}
// Clear cookies
res.clearCookie('access_token');
res.clearCookie('refresh_token', { path: '/api/auth/refresh' });
res.json({ success: true });
});
RS256 vs HS256: Why Asymmetric Signing Matters
HS256 (HMAC) uses a shared secret — the same key signs and verifies tokens. RS256 (RSA) uses a key pair — the private key signs, and the public key verifies.
| Aspect | HS256 | RS256 |
|---|---|---|
| Key management | One shared secret | Private key + public key |
| Verification | Requires the secret (can also forge tokens) | Only needs public key (cannot forge) |
| Microservices | Every service needs the secret | Services only need the public key |
| Key rotation | All services must update simultaneously | Only the auth service needs the new private key |
In a microservices architecture, HS256 means every service that validates tokens has the power to create tokens. If any service is compromised, the attacker can forge tokens for any user. With RS256, only the authentication service has the private key. Other services have the public key, which can only verify — not create — tokens.
// Generate RS256 key pair
// openssl genrsa -out private.pem 2048
// openssl rsa -in private.pem -pubout -out public.pem
// Auth service (has private key)
const privateKey = fs.readFileSync('private.pem');
const token = jwt.sign(payload, privateKey, { algorithm: 'RS256' });
// Other services (only have public key)
const publicKey = fs.readFileSync('public.pem');
const decoded = jwt.verify(token, publicKey, { algorithms: ['RS256'] });
Rate Limiting Authentication Endpoints
Authentication endpoints are prime targets for brute-force attacks. I apply aggressive rate limiting to login, registration, and password reset endpoints:
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 10,
message: { error: 'Too many login attempts. Try again in 15 minutes.' },
keyGenerator: (req) => req.body.email || req.ip,
});
const refreshLimiter = rateLimit({
windowMs: 60 * 1000,
max: 5,
keyGenerator: (req) => req.cookies.refresh_token || req.ip,
});
app.post('/api/auth/login', loginLimiter, loginHandler);
app.post('/api/auth/refresh', refreshLimiter, refreshHandler);
I rate limit by email address for login attempts (not just IP) because attackers often use distributed IP addresses. After 10 failed attempts for a specific email, that account is locked for 15 minutes regardless of the source IP.
Frequently Asked Questions
Should I Use JWT or Session-Based Authentication?
JWT is better for stateless APIs, microservices architectures, and mobile clients. Session-based authentication (server-side sessions with a session ID cookie) is simpler and provides easier revocation — you just delete the session from the store. I use JWT for APIs consumed by multiple clients and sessions for traditional server-rendered web applications. If you are building a monolithic web app, sessions are often the simpler and more secure choice.
How Long Should Access Tokens Last?
15 minutes is the sweet spot for most applications. Short enough to limit damage from stolen tokens, long enough to avoid excessive refresh requests. I never go above 60 minutes. Some tutorials set 24-hour or even 30-day access token expiration — this is dangerous. If a token is stolen, the attacker has a long window of access with no way to revoke it (unless you implement a token blacklist, which negates JWT’s stateless advantage).
Can JWTs Be Hacked?
JWTs themselves are cryptographically secure when implemented correctly. The vulnerabilities come from implementation mistakes: storing tokens in localStorage (XSS theft), using weak signing secrets, not validating the algorithm claim, not checking expiration, or not implementing revocation. The token format is not the weak point — the surrounding system is.
What Happens When the Signing Key Is Compromised?
If the signing key is compromised, an attacker can forge tokens for any user. For HS256, rotate the secret immediately — this invalidates all existing tokens and forces all users to re-authenticate. For RS256, rotate the private key and publish the new public key. I keep a key ID (kid) in the JWT header and support multiple public keys during rotation, so valid tokens signed with the old key can coexist with tokens signed with the new key during the transition period.
Should I Put User Permissions in the JWT?
Include the role (admin, user, editor) but not fine-grained permissions. Roles change infrequently and are safe to cache in a 15-minute token. Fine-grained permissions (can_edit_post_123) change more often and can make the token excessively large. Fetch fine-grained permissions from the database when needed — the role in the JWT tells you whether the user might have the permission, and the database lookup confirms it.
The Bottom Line
JWT authentication is one of those technologies where the basics are easy and the security is hard. The happy path — sign a token, verify a token — takes 20 minutes to implement. Doing it securely — httpOnly cookies, RS256 signing, refresh token rotation, token revocation, rate limiting — takes a few days but prevents the kind of security incidents that can damage your product and your users’ trust.
Start with the access token plus refresh token pattern. Store both in httpOnly cookies with SameSite protection. Use RS256 signing from day one. Keep access tokens short-lived (15 minutes) and implement refresh token rotation. These decisions cost very little upfront and prevent vulnerabilities that are expensive to fix after deployment.
Product recommendations are based on independent research and testing. We may earn a commission through affiliate links at no extra cost to you.