JWT Best Practices and Common Pitfalls
JWTs are deceptively simple — here's how to avoid the security vulnerabilities, performance traps, and architectural mistakes that plague most implementations.

JSON Web Tokens are everywhere — authentication, API authorization, session management. Their simplicity is appealing: a self-contained, signed payload that encodes user information. But that simplicity hides a minefield of security vulnerabilities and architectural trade-offs that most tutorials skip.
What a JWT Actually Is
A JWT is three base64url-encoded JSON objects separated by dots: header, payload, signature.
// Header: algorithm and token type
{ "alg": "RS256", "typ": "JWT" }
// Payload: claims (data)
{
"sub": "user_123",
"name": "Alice",
"role": "admin",
"iat": 1587654321,
"exp": 1587657921
}
// Signature: cryptographic proof that the header and payload are unmodified
// RSASHA256(base64url(header) + "." + base64url(payload), privateKey)The signature guarantees integrity — if anyone modifies the payload, the signature won't match. But it doesn't encrypt anything. The payload is readable by anyone with the token.
Algorithm Confusion Attacks
The most dangerous JWT vulnerability is the alg header manipulation. Some libraries trust the algorithm specified in the token rather than enforcing one server-side.
// ❌ Trusting the algorithm from the token header
const payload = jwt.verify(token, publicKey);
// An attacker can set alg: "none" and skip the signature entirely
// Or set alg: "HS256" and sign with the public key as the HMAC secret
// ✅ Always specify the expected algorithm
const payload = jwt.verify(token, publicKey, {
algorithms: ["RS256"], // Only accept RS256
});Always lock down the accepted algorithms. Never allow "none". If you're using asymmetric signing (RS256), never accept symmetric (HS256).
Symmetric vs. Asymmetric Signing
| Algorithm | Signing | Verification | Use case |
|---|---|---|---|
| HS256 | Shared secret | Same shared secret | Single service |
| RS256 | Private key | Public key | Microservices, third-party verification |
| ES256 | Private key | Public key | Same as RS256, smaller signatures |
// HS256: same secret signs and verifies
// ❌ Every service that verifies tokens knows the signing secret
const token = jwt.sign(payload, "shared-secret", { algorithm: "HS256" });
jwt.verify(token, "shared-secret", { algorithms: ["HS256"] });
// RS256: only the auth service has the private key
// ✅ Other services verify with the public key — can't forge tokens
const token = jwt.sign(payload, privateKey, { algorithm: "RS256" });
jwt.verify(token, publicKey, { algorithms: ["RS256"] });Use RS256 or ES256 whenever multiple services need to verify tokens. HS256 means every verifier can also forge tokens.
Keep Payloads Small
JWTs are sent with every request — in headers, cookies, or URL parameters. A bloated payload wastes bandwidth and can hit header size limits.
// ❌ Stuffing everything into the JWT
const token = jwt.sign({
sub: "user_123",
name: "Alice Johnson",
email: "alice@example.com",
role: "admin",
permissions: ["read", "write", "delete", "manage-users", "view-analytics"],
organization: { id: "org_456", name: "Acme Corp", plan: "enterprise" },
preferences: { theme: "dark", language: "en", timezone: "UTC" },
}, key);
// This token is 500+ bytes — sent with EVERY request
// ✅ Minimal payload — look up details when needed
const token = jwt.sign({
sub: "user_123",
role: "admin",
org: "org_456",
}, key, { expiresIn: "15m" });
// ~200 bytes — essential claims onlyPut the user ID, role, and any data needed for authorization decisions. Everything else can be fetched from a database or cache when needed.
Expiration and Revocation
JWTs are stateless — once issued, the server can't revoke them without additional infrastructure. This is the fundamental trade-off.
// Short expiration limits the damage window
const accessToken = jwt.sign(
{ sub: userId, role: user.role },
privateKey,
{
algorithm: "RS256",
expiresIn: "15m", // Short-lived
issuer: "https://auth.myapp.com",
audience: "https://api.myapp.com",
},
);// For immediate revocation, you need a blocklist
const revokedTokens = new Set<string>();
function verifyToken(token: string) {
const payload = jwt.verify(token, publicKey, {
algorithms: ["RS256"],
issuer: "https://auth.myapp.com",
audience: "https://api.myapp.com",
});
// Check if this specific token has been revoked
if (revokedTokens.has(payload.jti)) {
throw new Error("Token revoked");
}
return payload;
}A blocklist is a compromise — you lose pure statelessness but gain revocation. Keep the blocklist in Redis with TTL matching the token's max lifetime so entries auto-expire.
Validate Everything
// ❌ Minimal verification — many attack vectors remain
const payload = jwt.verify(token, key);
// ✅ Full verification — close every loophole
const payload = jwt.verify(token, publicKey, {
algorithms: ["RS256"], // Prevent algorithm confusion
issuer: "https://auth.myapp.com", // Reject tokens from other issuers
audience: "https://api.myapp.com", // Reject tokens meant for other services
clockTolerance: 30, // Allow 30s clock skew
maxAge: "1h", // Reject tokens older than 1 hour
});
// Additional checks
if (!payload.sub) throw new Error("Missing subject claim");
if (!payload.role) throw new Error("Missing role claim");When Not to Use JWTs
JWTs aren't always the right choice. If your application runs on a single server and doesn't need cross-service token verification, server-side sessions are simpler and more secure.
| Scenario | JWT | Server-side session |
|---|---|---|
| Single server | Unnecessary complexity | ✅ Simpler |
| Microservices | ✅ No shared session store | Requires shared store |
| Need instant revocation | Requires blocklist | ✅ Delete session |
| Cross-domain auth | ✅ Self-contained | Requires cookie sharing |
Key Takeaways
- Always specify the algorithm when verifying — never trust the token's
algheader - Use RS256/ES256 for multi-service architectures — HS256 means every verifier can forge tokens
- Keep payloads minimal — user ID, role, and authorization-critical claims only
- Short expiration (15 min) + refresh tokens limits the damage window of stolen tokens
- JWTs can't be revoked without a blocklist — this is the fundamental stateless trade-off
- Validate issuer, audience, and algorithm on every verification — not just the signature


