Skip to content

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.

3 min read
JWT token structure showing header, payload, and signature sections

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.

tstypescript
// 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.

tstypescript
// ❌ 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

AlgorithmSigningVerificationUse case
HS256Shared secretSame shared secretSingle service
RS256Private keyPublic keyMicroservices, third-party verification
ES256Private keyPublic keySame as RS256, smaller signatures
tstypescript
// 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.

tstypescript
// ❌ 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 only

Put 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.

tstypescript
// 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",
  },
);
tstypescript
// 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

tstypescript
// ❌ 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.

ScenarioJWTServer-side session
Single serverUnnecessary complexity✅ Simpler
Microservices✅ No shared session storeRequires shared store
Need instant revocationRequires blocklist✅ Delete session
Cross-domain auth✅ Self-containedRequires cookie sharing

Key Takeaways

  1. Always specify the algorithm when verifying — never trust the token's alg header
  2. Use RS256/ES256 for multi-service architectures — HS256 means every verifier can forge tokens
  3. Keep payloads minimal — user ID, role, and authorization-critical claims only
  4. Short expiration (15 min) + refresh tokens limits the damage window of stolen tokens
  5. JWTs can't be revoked without a blocklist — this is the fundamental stateless trade-off
  6. Validate issuer, audience, and algorithm on every verification — not just the signature
Wilfredo Rujel

Wilfredo Rujel

Full Stack Software Engineer

Share this postX