JWT Security Best Practices for Production
JWT Security Best Practices for Production
JSON Web Tokens (JWTs) are widely used for authentication and authorization in web applications. While convenient, they introduce security risks that are easy to overlook. This guide covers the practices that keep JWTs safe in production.
Always Validate the Signature
The most critical rule. Never trust a JWT payload without verifying its signature. A JWT has three parts: header, payload, and signature. Without verification, an attacker can modify the payload at will.
import jwt from "jsonwebtoken";
// CORRECT: Verify with your secret
const payload = jwt.verify(token, process.env.JWT_SECRET);
// WRONG: Just decoding without verification
const payload = jwt.decode(token); // Anyone can craft this
Block the "none" Algorithm
The JWT spec includes an alg: "none" option that produces unsigned tokens. If your library accepts this, attackers can forge any token. Explicitly whitelist allowed algorithms.
const payload = jwt.verify(token, secret, {
algorithms: ["HS256"], // Whitelist only what you use
});
Most modern JWT libraries have fixed this, but always verify your configuration.
Use Short Expiration Times
JWTs cannot be revoked once issued (without maintaining a blocklist). Keep access token lifetimes short to limit the damage window if a token is compromised.
const token = jwt.sign(
{ userId: user.id, role: user.role },
process.env.JWT_SECRET,
{ expiresIn: "15m" } // 15 minutes for access tokens
);
Use refresh tokens (stored server-side or in HTTP-only cookies) to issue new access tokens without requiring re-authentication.
Store Tokens Securely
Never store JWTs in localStorage. It is accessible to any JavaScript on the page, making it vulnerable to XSS attacks.// WRONG: Vulnerable to XSS
localStorage.setItem("token", jwt);
// BETTER: HTTP-only cookie
res.cookie("token", jwt, {
httpOnly: true, // Not accessible via JavaScript
secure: true, // HTTPS only
sameSite: "lax", // CSRF protection
maxAge: 900000, // 15 minutes
});
HTTP-only cookies are not accessible to JavaScript, eliminating the XSS vector. Add sameSite for CSRF protection.
Minimize Payload Data
JWTs are not encrypted by default — they are only signed. Anyone can decode the payload (it is just Base64). Never include sensitive data.
// WRONG: Sensitive data in payload
{
"userId": 42,
"email": "[email protected]",
"ssn": "123-45-6789", // Never!
"creditCard": "4111..." // Never!
}
// CORRECT: Minimal claims
{
"sub": "42",
"role": "user",
"iat": 1717500000,
"exp": 1717500900
}
Implement Token Rotation
When a refresh token is used, issue a new refresh token and invalidate the old one. This limits the window for replay attacks.
async function refreshAccessToken(refreshToken) {
const stored = await db.refreshToken.findUnique({
where: { token: refreshToken },
});
if (!stored || stored.usedAt) {
// Token reuse detected — revoke all tokens for this user
await db.refreshToken.deleteMany({
where: { userId: stored.userId },
});
throw new Error("Token reuse detected");
}
// Mark old token as used
await db.refreshToken.update({
where: { id: stored.id },
data: { usedAt: new Date() },
});
// Issue new tokens
return {
accessToken: generateAccessToken(stored.userId),
refreshToken: generateRefreshToken(stored.userId),
};
}
Choose the Right Signing Algorithm
HS256 (HMAC + SHA-256): Symmetric — same secret signs and verifies. Simple but the secret must be shared with every service that verifies tokens. RS256 (RSA + SHA-256): Asymmetric — private key signs, public key verifies. Better for distributed systems where multiple services verify tokens but only one issues them. ES256 (ECDSA + SHA-256): Asymmetric with smaller keys than RSA. Preferred for new systems that need asymmetric signing.Conclusion
JWTs are powerful but demand careful handling. Verify signatures, block the "none" algorithm, use short expirations, store in HTTP-only cookies, minimize payload data, rotate refresh tokens, and choose the right signing algorithm. Security is not a feature you add later — build it in from the start.