JWT Security Best Practices: A Complete Guide for Developers
OverMCP Team
TL;DR: To securely handle JWT tokens in a web app, always store them in httpOnly cookies (not localStorage), use short expiration times with refresh tokens, validate the signature and claims on every request, and enforce HTTPS. Never trust the token's payload without verification.
---
JSON Web Tokens (JWTs) are everywhere in modern web apps — especially in vibe-coded applications built with AI tools. They're compact, self-contained, and easy to implement. But they're also easy to misuse. A single misconfiguration can expose your users' accounts to token theft, replay attacks, or privilege escalation.
This guide walks through the JWT security best practices you need to know — from storage and transmission to validation and rotation. Whether you're building with Next.js, Express, or a serverless framework, these principles apply.
Why JWT Security Matters
JWTs are bearer tokens: whoever possesses the token can access the resources it grants. If an attacker steals your JWT, they can impersonate the user until the token expires. Unlike session IDs, JWTs are often self-contained — they carry claims like userId and role in the payload. If you don't validate the signature, an attacker can forge arbitrary claims.
Common vulnerabilities include:
localStorage (accessible to XSS)alg header (algorithm confusion attacks)## JWT Security Best Practices: Step by Step
1. Store Tokens in httpOnly Cookies (Not localStorage)
Many tutorials tell you to store the JWT in localStorage after login. Don't. localStorage is accessible to any JavaScript running on your domain — including injected scripts from an XSS attack. httpOnly cookies are not accessible via JavaScript, so they're immune to XSS.
Example (Express + cookie-parser):
// Setting the cookie
res.cookie('token', jwtToken, {
httpOnly: true,
secure: true, // HTTPS only
sameSite: 'strict', // CSRF protection
maxAge: 15 * 60 * 1000 // 15 minutes
});Frontend fetch:
fetch('/api/protected', {
credentials: 'include' // sends cookies
});Trade-off: If you need to access the token client-side (e.g., for decoding claims), consider storing a separate non-sensitive copy in memory, but never in localStorage.
2. Use Short Expiration + Refresh Tokens
Short-lived access tokens (15 minutes or less) limit the damage if a token is stolen. But users don't want to log in every 15 minutes. Use a refresh token (long-lived, stored in a separate httpOnly cookie) to obtain new access tokens silently.
Refresh token rotation: Issue a new refresh token each time one is used, and invalidate the old one. This prevents replay if a refresh token is stolen.
// Refresh endpoint logic
app.post('/refresh', async (req, res) => {
const refreshToken = req.cookies.refreshToken;
if (!refreshToken) return res.sendStatus(401);
try {
const payload = jwt.verify(refreshToken, REFRESH_SECRET);
// Invalidate old refresh token (e.g., delete from DB)
await invalidateRefreshToken(refreshToken);
// Issue new tokens
const newAccessToken = jwt.sign({ userId: payload.userId }, ACCESS_SECRET, { expiresIn: '15m' });
const newRefreshToken = jwt.sign({ userId: payload.userId }, REFRESH_SECRET, { expiresIn: '7d' });
await storeRefreshToken(newRefreshToken, payload.userId);
// Set cookies
res.cookie('token', newAccessToken, { httpOnly: true, secure: true, sameSite: 'strict', maxAge: 15*60*1000 });
res.cookie('refreshToken', newRefreshToken, { httpOnly: true, secure: true, sameSite: 'strict', maxAge: 7*24*60*60*1000 });
res.json({ ok: true });
} catch (err) {
res.sendStatus(403);
}
});3. Validate the Signature and Claims Every Time
Never skip JWT verification on the server. Use a strong secret (at least 256 bits) and always specify the algorithm explicitly to avoid algorithm confusion attacks.
// BAD: relies on the header's 'alg'
const payload = jwt.verify(token, secret);
// GOOD: explicitly require RS256 or HS256
const payload = jwt.verify(token, secret, { algorithms: ['HS256'] });Also validate:
iss (issuer) matches your appaud (audience) matches your appexp (expiration) — the library does this, but double-checkrole if needed4. Use Asymmetric Signing (RS256) for Microservices
If you have multiple services that need to verify tokens, use a public/private key pair (RS256). The authentication service signs with the private key, and other services verify with the public key. This way, only the auth service can issue tokens.
// Signing (auth service)
const token = jwt.sign(payload, privateKey, { algorithm: 'RS256', expiresIn: '15m' });
// Verification (any service)
const payload = jwt.verify(token, publicKey, { algorithms: ['RS256'] });5. Never Include Sensitive Data in the Payload
The JWT payload is base64-encoded, not encrypted. Anyone with the token can read it. Never put passwords, credit card numbers, or other sensitive info in the payload. If you need to carry sensitive data, use JWE (JSON Web Encryption) or store a reference ID.
6. Implement Token Revocation (Blacklist or Version)
JWTs are stateless — you can't invalidate them before they expire unless you maintain a blacklist or use a token version. For high-security apps, store a token version in your database and check it on each request.
// User schema
{
userId: ObjectId,
tokenVersion: { type: Number, default: 0 }
}
// Verification middleware
const user = await User.findById(payload.userId);
if (user.tokenVersion !== payload.tokenVersion) {
throw new Error('Token revoked');
}7. Use HTTPS Everywhere
This should be obvious, but JWTs sent over HTTP can be intercepted. Always use HTTPS in production. Set secure: true on cookies, and for mobile apps, use certificate pinning.
8. Rotate Secrets Periodically
Change your JWT signing secrets every 90 days (or after a breach). This invalidates all existing tokens, forcing users to re-authenticate. Plan for token rotation by keeping the previous secret for a grace period to validate tokens signed before the rotation.
9. Log and Monitor Token Usage
Detect anomalies like:
Use a security scanning tool like OverMCP to audit your app for exposed secrets or misconfigured JWT handling automatically.
Common Mistakes to Avoid
alg: 'none'Conclusion
JWT security isn't complicated, but it requires discipline. Follow these JWT security best practices: use httpOnly cookies, short expiration with refresh tokens, explicit algorithm validation, and never trust the payload without verifying the signature. Test your implementation with automated security scanning to catch misconfigurations early.
FAQ
What is the safest way to store JWT tokens in the browser?
The safest way is to store them in httpOnly, secure, sameSite cookies. This prevents access from JavaScript (XSS) and reduces CSRF risk. Avoid localStorage or sessionStorage.
How often should I rotate JWT secrets?
Rotate your JWT signing secrets every 90 days, or immediately after a suspected breach. Keep the previous secret for a short overlap period to avoid invalidating valid tokens.
Can I revoke a JWT before it expires?
Yes, by implementing a token blacklist or a token version field in your database. Check the version or blacklist on every request. This adds state to your auth system but gives you control.