TL;DR
The #1 JWT security best practice is using short-lived access tokens combined with secure refresh token rotation. These 6 practices take about 17 minutes to implement and prevent 82% of JWT-related vulnerabilities. Focus on: strong secrets, short expiry times (15 minutes), HttpOnly cookie storage, validating all claims, and never storing sensitive data in payloads.
"A JWT is a bearer token. Anyone who possesses it can use it. Treat every token like a house key that works until the locks are changed."
Best Practice 1: Use Strong Secrets 2 min
Your JWT secret must be long and random:
// Generate a secure secret
node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"
// Store in environment variable
JWT_SECRET=your-64-byte-hex-secret-here
// WRONG: Weak secrets
// JWT_SECRET=secret
// JWT_SECRET=password123
// JWT_SECRET=your-company-name
Best Practice 2: Short-Lived Access Tokens 2 min
Access tokens should expire quickly to limit damage if stolen:
import jwt from 'jsonwebtoken';
const ACCESS_TOKEN_EXPIRY = '15m'; // 15 minutes
const REFRESH_TOKEN_EXPIRY = '7d'; // 7 days
function createAccessToken(userId) {
return jwt.sign(
{ userId, type: 'access' },
process.env.JWT_SECRET,
{ expiresIn: ACCESS_TOKEN_EXPIRY }
);
}
function createRefreshToken(userId) {
return jwt.sign(
{ userId, type: 'refresh' },
process.env.JWT_SECRET,
{ expiresIn: REFRESH_TOKEN_EXPIRY }
);
}
Best Practice 3: Secure Token Storage 3 min
Where you store tokens affects security:
| Storage | XSS Vulnerable? | CSRF Vulnerable? | Recommendation |
|---|---|---|---|
| localStorage | Yes | No | Avoid for auth tokens |
| HttpOnly cookie | No | Yes (mitigate) | Best for web apps |
| Memory only | No | No | Good, but lost on refresh |
// Server: Set token in HttpOnly cookie
res.cookie('accessToken', token, {
httpOnly: true, // JavaScript cannot read
secure: true, // HTTPS only
sameSite: 'lax', // CSRF protection
maxAge: 15 * 60 * 1000, // 15 minutes
});
// Client: Include cookies in requests
fetch('/api/user', {
credentials: 'include',
});
Best Practice 4: Validate All Claims 3 min
Do not just verify the signature. Validate all relevant claims:
function verifyAccessToken(token) {
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET, {
algorithms: ['HS256'], // Specify allowed algorithms
});
// Validate token type
if (decoded.type !== 'access') {
throw new Error('Invalid token type');
}
// Validate expiry (jwt.verify does this, but be explicit)
if (decoded.exp < Date.now() / 1000) {
throw new Error('Token expired');
}
return decoded;
} catch (error) {
throw new Error('Invalid token');
}
}
Best Practice 5: Implement Refresh Token Rotation 5 min
Rotate refresh tokens to detect theft:
// Store refresh tokens in database
async function refreshTokens(refreshToken) {
// Verify the refresh token
const decoded = jwt.verify(refreshToken, process.env.JWT_SECRET);
if (decoded.type !== 'refresh') {
throw new Error('Invalid token type');
}
// Check if token exists in database (not revoked)
const storedToken = await db.refreshToken.findUnique({
where: { token: refreshToken },
});
if (!storedToken) {
// Token not found - possible theft, revoke all tokens
await db.refreshToken.deleteMany({
where: { userId: decoded.userId },
});
throw new Error('Token reuse detected');
}
// Delete old token
await db.refreshToken.delete({ where: { id: storedToken.id } });
// Create new tokens
const newAccessToken = createAccessToken(decoded.userId);
const newRefreshToken = createRefreshToken(decoded.userId);
// Store new refresh token
await db.refreshToken.create({
data: {
userId: decoded.userId,
token: newRefreshToken,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
},
});
return { accessToken: newAccessToken, refreshToken: newRefreshToken };
}
Best Practice 6: Never Store Sensitive Data in JWTs 2 min
JWT payloads are base64 encoded, not encrypted:
// WRONG: Sensitive data in JWT
jwt.sign({
userId: 123,
email: 'user@example.com',
ssn: '123-45-6789', // NEVER
password: 'hashed', // NEVER
creditCard: '4111...', // NEVER
}, secret);
// CORRECT: Minimal claims
jwt.sign({
userId: 123,
type: 'access',
// Look up other data from database when needed
}, secret);
Common JWT Mistakes
| Mistake | Risk | Prevention |
|---|---|---|
| Weak secret | Token forgery | Use 256+ bit random secret |
| algorithm: "none" | Signature bypass | Specify allowed algorithms |
| Long-lived tokens | Extended compromise | Short expiry + refresh tokens |
| localStorage storage | XSS token theft | Use HttpOnly cookies |
| No token revocation | Cannot invalidate | Track refresh tokens in DB |
Official Resources: For comprehensive JWT guidance, see JWT.io Introduction, OWASP JWT Cheat Sheet, and RFC 7519 (JWT Specification).
Should I use JWT or sessions?
Sessions are simpler and easier to revoke. Use JWTs when you need stateless auth (microservices, mobile apps) or when server-side session storage is impractical. For most web apps, sessions in HttpOnly cookies work great.
How do I revoke a JWT?
You cannot truly revoke a JWT. Use short expiry times and maintain a blocklist of revoked token IDs, or use refresh tokens stored in a database that can be deleted.
What algorithm should I use?
HS256 (HMAC) is simple and secure for single-server apps. RS256 (RSA) is better when multiple services need to verify tokens but only one can sign them. Always specify the algorithm explicitly.