2024-10-06 6 min read

JWT Security Pitfalls: What Your First Production API Gets Wrong

Most developers ship JWTs with critical flaws. We break down the five mistakes that compromise real production APIs and how to fix them.

You've built your API, added JWT authentication, and deployed to production. Your users can log in. Everything works. And then someone finds out they can tamper with their token and grant themselves admin access.

JWT security isn't complicated, but the gaps between "it works" and "it's secure" are where most first-time production APIs fail. Here are the mistakes we see constantly—and how to avoid them.

1. Trusting Unsigned Tokens or Weak Secrets

The most dangerous mistake: using a weak or hardcoded secret key, or worse, not verifying the signature at all.

Your secret must be:

  • At least 256 bits of entropy (use a cryptographically secure random generator)
  • Stored in environment variables, never in code
  • Rotated periodically
typescript
// ❌ DON'T DO THIS
const secret = "mysecret123";
const token = jwt.sign(payload, secret);

// ✅ DO THIS
const secret = process.env.JWT_SECRET; // e.g., crypto.randomBytes(32).toString('hex')
jwt.verify(token, secret, { algorithms: ['HS256'] });

Always explicitly specify the algorithm during verification. Never trust the

code
alg
header alone—an attacker can switch it to
code
none
and bypass signature validation entirely.

typescript
// ❌ VULNERABLE
jwt.verify(token, secret);

// ✅ SECURE
jwt.verify(token, secret, { algorithms: ['HS256'] });

2. Not Implementing Token Expiration

A stolen token is only valuable if it works forever. Without expiration, a compromised token becomes a permanent backdoor.

Set reasonable expiration times and enforce them:

python
import jwt
from datetime import datetime, timedelta

payload = {
    'user_id': 123,
    'exp': datetime.utcnow() + timedelta(hours=1),
    'iat': datetime.utcnow()
}

token = jwt.encode(payload, secret, algorithm='HS256')

On verification, the library will automatically reject expired tokens. Pair short-lived access tokens (15 minutes) with refresh tokens to balance security and usability.

3. Storing Sensitive Data in the Payload

JWT tokens are encoded, not encrypted. Anyone can decode the payload and read its contents.

bash
# Anyone can do this
echo "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U" | cut -d'.' -f2 | base64 -d

Never store passwords, API keys, credit card numbers, or PII in tokens. Store only immutable identifiers:

typescript
// ❌ DON'T DO THIS
const payload = {
  user_id: user.id,
  email: user.email,
  role: user.role,
  password_hash: user.password_hash  // NEVER
};

// ✅ DO THIS
const payload = {
  user_id: user.id,
  role: user.role
};

4. Missing Token Revocation

You can't revoke a valid, unexpired JWT without infrastructure. If a user logs out or their account is compromised, their token still works.

Implement one of these approaches:

Blacklist (simple, scales okay): Store invalidated token IDs in Redis with a TTL matching the token expiration.

typescript
// On logout
await redis.setex(`token_blacklist:${tokenId}`, expirationTime, '1');

// On request
const isBlacklisted = await redis.get(`token_blacklist:${tokenId}`);
if (isBlacklisted) throw new UnauthorizedError();

Short expiration + refresh tokens (recommended): Keep access tokens short-lived (15 min). Refresh tokens are long-lived but can be revoked in a database. When a user logs out, revoke their refresh token.

5. Using the Wrong Algorithm

HS256 (HMAC with SHA-256) is symmetric: the same secret signs and verifies tokens. This works fine for monolithic APIs but creates key-sharing problems in distributed systems.

RS256 (RSA) is asymmetric: a private key signs, a public key verifies. Better for microservices and third-party integrations.

typescript
const fs = require('fs');

const privateKey = fs.readFileSync('private.key', 'utf8');
const publicKey = fs.readFileSync('public.key', 'utf8');

// Sign with private key
const token = jwt.sign(payload, privateKey, { algorithm: 'RS256' });

// Verify with public key
jwt.verify(token, publicKey, { algorithms: ['RS256'] });

Choose based on your architecture, but be intentional about it.

The Bottom Line

JWT security is about three things: strong keys, short lifespans, and minimal payload. If you're building an API at LavaPi or anywhere else, start with these five fixes before pushing to production. Token compromise is silent and hard to detect—lock the door now.

Share
LP

LavaPi Team

Digital Engineering Company

All articles