JWT Security Best Practices
JSON Web Tokens are used everywhere for authentication and authorization. They are also misused everywhere. This guide covers the mistakes that matter and the patterns that work.
Always Validate the Signature
A JWT without signature validation is just a base64-encoded JSON blob that anyone can forge.
import jwt from "jsonwebtoken";
const SECRET = process.env.JWT_SECRET!;
function verifyToken(token: string) {
try {
return jwt.verify(token, SECRET, {
algorithms: ["HS256"], // explicitly specify allowed algorithms
});
} catch {
throw new Error("Invalid token");
}
}
Never decode without verifying. jwt.decode() does not check signatures.
Reference: RFC 7519 — JSON Web Token
Specify Allowed Algorithms
The alg field in the JWT header tells the library which algorithm to use. If you do not restrict this, attackers can:
- Set
alg: "none"to bypass signature entirely. - Set
alg: "HS256"on a system expecting RS256, using the public key as the HMAC secret.
Always pass the algorithms option:
// GOOD
jwt.verify(token, key, { algorithms: ["RS256"] });
// BAD — accepts whatever the token says
jwt.verify(token, key);
Reference: Auth0 — Critical JWT Vulnerability
Use Asymmetric Keys for Distributed Systems
If multiple services verify tokens, use RS256 or ES256 (asymmetric) instead of HS256 (symmetric). With HMAC, every service that verifies also has the secret to forge tokens.
- HS256: One shared secret. OK for a single service.
- RS256/ES256: Private key signs, public key verifies. Services only need the public key.
Reference: RFC 7518 — JSON Web Algorithms
Set Short Expiry (exp)
Tokens should expire. The shorter the better, balanced against usability:
- Access tokens: 5–15 minutes.
- Refresh tokens: hours to days, stored securely.
const token = jwt.sign(
{ sub: userId, role: "user" },
SECRET,
{ algorithm: "HS256", expiresIn: "15m" }
);
Always validate exp on the server. Most libraries do this by default, but confirm.
Use iss, aud, and sub Claims
Standard claims prevent token misuse across services:
iss(issuer): who created the token.aud(audience): who the token is intended for.sub(subject): who the token represents.
const token = jwt.sign(
{ sub: userId, role: "user" },
SECRET,
{
algorithm: "HS256",
expiresIn: "15m",
issuer: "auth.example.com",
audience: "api.example.com",
}
);
// Verify
jwt.verify(token, SECRET, {
algorithms: ["HS256"],
issuer: "auth.example.com",
audience: "api.example.com",
});
Reference: RFC 7519 — Registered Claim Names
Do Not Store Sensitive Data in the Payload
JWTs are base64-encoded, not encrypted. Anyone with the token can read the payload. Never put passwords, credit card numbers, PII, or internal secrets in the claims.
If you need encrypted tokens, use JWE (JSON Web Encryption): RFC 7516 — JWE
Store Tokens Securely
| Storage | XSS Safe | CSRF Safe | Recommendation |
|---|---|---|---|
| HttpOnly cookie | Yes | Need SameSite/token | Best for web apps |
| localStorage | No | Yes | Avoid for auth tokens |
| sessionStorage | No | Yes | Avoid for auth tokens |
| Memory (variable) | Yes | Yes | OK but lost on refresh |
For web applications, use HttpOnly, Secure, SameSite=Lax cookies.
Reference: OWASP — Session Management Cheat Sheet
Implement Token Refresh
Short-lived access tokens with longer-lived refresh tokens:
- Client authenticates → receives access token (15 min) + refresh token (7 days).
- Access token expires → client sends refresh token to get a new access token.
- Refresh token is single-use — issue a new one with each refresh (rotation).
- Store refresh tokens server-side so you can revoke them.
Token Revocation
JWTs are stateless by design, which means you cannot "invalidate" one without server-side state. Options:
- Short expiry + refresh tokens: the access token naturally expires; revoke the refresh token server-side.
- Token blocklist: maintain a list of revoked token IDs (
jticlaim) checked on each request. Use Redis with TTL matching the token expiry. - Token versioning: store a version counter per user; bump it on logout/password change; reject tokens with old versions.
Key Rotation
Signing keys should be rotated periodically:
- Generate a new key pair.
- Start signing new tokens with the new key.
- Continue accepting tokens signed with the old key until they expire.
- Remove the old key after the longest token TTL has passed.
For asymmetric keys, publish the public keys via a JWKS endpoint:
Reference: RFC 7517 — JSON Web Key
Common Mistakes
- Trusting
jwt.decode()for authentication (no signature check). - Not restricting
algorithmsin verification. - Storing tokens in localStorage (vulnerable to XSS).
- Setting long expiry on access tokens (hours or days).
- Putting PII in claims without encryption.
- Not validating
issandaudclaims.
Related Guides
Content is AI-assisted and reviewed by our team, but issues may be missed and best practices evolve rapidly, send corrections to [email protected]. Always consult official documentation and validate key implementation decisions before making design or security choices.
