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.
Further Reading
Put these practices into action with our step-by-step guides.
Verify Your JWT Security
Scan your application for JWT vulnerabilities.