Home/Blog/JWT Security Best Practices: Token Signing, Validation, and Common Vulnerabilities
Development

JWT Security Best Practices: Token Signing, Validation, and Common Vulnerabilities

Master JWT security with this comprehensive guide covering token structure, signing algorithms, validation best practices, secure storage, and common vulnerabilities like algorithm confusion and token leakage.

JWT Security Best Practices: Token Signing, Validation, and Common Vulnerabilities

JSON Web Tokens (JWTs) are the de facto standard for API authentication and authorization. However, their apparent simplicity hides numerous security pitfalls. This guide covers JWT security best practices, common vulnerabilities, and how to implement tokens correctly.

JWT Structure

┌─────────────────────────────────────────────────────────────────┐
│                        JWT STRUCTURE                            │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  Header          Payload           Signature                    │
│  ──────          ───────           ─────────                    │
│  eyJhbGci...  .  eyJzdWIi...   .   SflKxwRJ...                  │
│                                                                 │
│  ┌──────────┐    ┌──────────────┐   ┌──────────────────────┐    │
│  │ {        │    │ {            │   │ HMACSHA256(          │    │
│  │  "alg":  │    │  "sub":"123",│   │   base64(header) +   │    │
│  │  "RS256",│    │  "name":"Jo",│   │   "." +              │    │
│  │  "typ":  │    │  "exp":12345,│   │   base64(payload),   │    │
│  │  "JWT"   │    │  "roles":[]  │   │   secret             │    │
│  │ }        │    │ }            │   │ )                    │    │
│  └──────────┘    └──────────────┘   └──────────────────────┘    │
│                                                                 │
│  Base64URL       Base64URL          Cryptographic              │
│  Encoded         Encoded            Signature                  │
│                                                                 │
│  ⚠️  NOT ENCRYPTED - Anyone can read header and payload!       │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

Signing Algorithm Selection

AlgorithmTypeKeyUse CaseSecurity
HS256HMACShared secretSingle serviceGood (if secret is strong)
HS384HMACShared secretSingle serviceBetter
HS512HMACShared secretSingle serviceBest HMAC
RS256RSAPublic/PrivateDistributed systemsGood
RS384RSAPublic/PrivateDistributed systemsBetter
RS512RSAPublic/PrivateDistributed systemsBest RSA
ES256ECDSAPublic/PrivateMobile, performanceRecommended
ES384ECDSAPublic/PrivateHigh securityBetter
ES512ECDSAPublic/PrivateHighest securityBest ECDSA
noneNoneNone❌ NEVER USEVulnerable

When to Use Each

Single Backend Service?
│
├─► Yes ─► HS256/HS384/HS512
│          (Shared secret, simpler)
│
└─► No ─► Multiple services verify tokens?
          │
          ├─► Yes ─► RS256 or ES256
          │          (Asymmetric, public key distribution)
          │
          └─► Performance critical?
              │
              ├─► Yes ─► ES256 (smaller signatures)
              │
              └─► No ─► RS256 (widest compatibility)

Standard Claims Reference

{
  // REGISTERED CLAIMS (RFC 7519)
  "iss": "https://auth.example.com",  // Issuer - who created the token
  "sub": "user_12345",                // Subject - who the token is about
  "aud": "https://api.example.com",   // Audience - intended recipient(s)
  "exp": 1735689600,                  // Expiration - Unix timestamp
  "nbf": 1735686000,                  // Not Before - token not valid before
  "iat": 1735686000,                  // Issued At - when token was created
  "jti": "unique-token-id-abc123",    // JWT ID - unique identifier

  // CUSTOM CLAIMS (your application)
  "roles": ["user", "admin"],
  "permissions": ["read:data", "write:data"],
  "tenant_id": "tenant_abc",
  "email": "[email protected]"
}

Token Validation Implementation

Correct Validation (Node.js Example)

const jwt = require('jsonwebtoken');

// CORRECT: Explicit algorithm, all claims validated
function validateToken(token) {
  try {
    const decoded = jwt.verify(token, publicKey, {
      algorithms: ['RS256'],  // NEVER use the alg from header
      issuer: 'https://auth.example.com',
      audience: 'https://api.example.com',
      clockTolerance: 30,  // 30 seconds for clock skew
    });

    // Additional custom validation
    if (!decoded.sub) {
      throw new Error('Missing subject claim');
    }

    return decoded;
  } catch (error) {
    if (error.name === 'TokenExpiredError') {
      throw new Error('Token expired');
    }
    if (error.name === 'JsonWebTokenError') {
      throw new Error('Invalid token');
    }
    throw error;
  }
}

What NOT to Do

// ❌ DANGEROUS: Trusts algorithm from token header
const decoded = jwt.verify(token, secret);  // Don't do this!

// ❌ DANGEROUS: No algorithm specified
jwt.verify(token, secret, {});

// ❌ DANGEROUS: Allows "none" algorithm
jwt.verify(token, secret, { algorithms: ['RS256', 'none'] });

// ❌ DANGEROUS: No issuer/audience validation
jwt.verify(token, secret, { algorithms: ['RS256'] });
// Attacker's valid token from different issuer would be accepted

Secure Token Storage

Web Applications

// ❌ WRONG: localStorage is XSS vulnerable
localStorage.setItem('token', accessToken);

// ❌ WRONG: sessionStorage is also XSS vulnerable
sessionStorage.setItem('token', accessToken);

// ✅ CORRECT: HttpOnly cookies (set by server)
// Server response:
res.cookie('access_token', token, {
  httpOnly: true,    // Not accessible via JavaScript
  secure: true,      // HTTPS only
  sameSite: 'strict', // CSRF protection
  maxAge: 900000,    // 15 minutes
  path: '/api'       // Only sent to API routes
});

// ✅ CORRECT: Memory only for SPAs
class TokenStore {
  #accessToken = null;  // Private, in-memory only

  setToken(token) {
    this.#accessToken = token;
  }

  getToken() {
    return this.#accessToken;
  }

  clear() {
    this.#accessToken = null;
  }
}

Backend-for-Frontend (BFF) Pattern

┌─────────────┐      Session       ┌─────────────┐      JWT        ┌─────────────┐
│   Browser   │◄───────────────────│     BFF     │◄───────────────►│   API       │
│   (SPA)     │    Cookie (ID)     │  (Backend)  │    Bearer       │  Services   │
└─────────────┘                    └─────────────┘                 └─────────────┘
                                          │
                                          ▼
                                   ┌─────────────┐
                                   │   Token     │
                                   │   Store     │
                                   └─────────────┘

Benefits:
• Tokens never reach browser (no XSS risk)
• Session cookies are HttpOnly
• BFF handles token refresh
• Can implement additional security layers

Common Vulnerabilities

1. Algorithm Confusion Attack

ATTACK SCENARIO:
┌─────────────────────────────────────────────────────────────────┐
│                                                                 │
│  1. Server expects RS256 (asymmetric)                          │
│     Public key: MIIBIjANBg...                                  │
│                                                                 │
│  2. Attacker changes header to HS256 (symmetric)               │
│     {"alg":"HS256","typ":"JWT"}                                │
│                                                                 │
│  3. Attacker signs with PUBLIC KEY as HMAC secret              │
│     HMACSHA256(header.payload, publicKey)                      │
│                                                                 │
│  4. Vulnerable server:                                         │
│     - Reads alg: "HS256" from header                           │
│     - Uses stored key for HMAC verification                    │
│     - Verification succeeds! (same key used both sides)        │
│                                                                 │
│  PREVENTION: NEVER trust the alg header!                       │
│  Always specify expected algorithm explicitly.                  │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

2. Token Leakage Vectors

VectorRiskMitigation
URL query stringsLogged in server logs, browser historyNever send tokens in URLs
Referrer headersToken leaked to external sitesUse Referrer-Policy: no-referrer
Browser storageXSS can steal tokensUse HttpOnly cookies
Error messagesToken exposed in stack tracesNever log full tokens
Client-side codeVisible in source/DevToolsKeep tokens in memory

3. Weak Secret Keys

// ❌ WEAK: Too short, predictable
const secret = 'secret123';
const secret = 'password';
const secret = 'jwt-secret';

// ❌ WEAK: Common patterns
const secret = process.env.APP_NAME + '-jwt';

// ✅ STRONG: Cryptographically random, sufficient length
// Generate with: openssl rand -base64 64
const secret = process.env.JWT_SECRET;
// Value: 'x8kH2nQ9...(64+ random bytes base64 encoded)'

// For HS256: minimum 256 bits (32 bytes) of entropy
// For HS512: minimum 512 bits (64 bytes) of entropy

Token Expiration Strategy

┌─────────────────────────────────────────────────────────────────┐
│                   TOKEN LIFETIME STRATEGY                       │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  Access Token: 5-15 minutes                                     │
│  ├─► Sent with every API request                               │
│  ├─► Short-lived limits breach impact                          │
│  └─► Refresh when expired or about to expire                   │
│                                                                 │
│  Refresh Token: 7-30 days                                       │
│  ├─► Used only to get new access tokens                        │
│  ├─► Stored more securely than access tokens                   │
│  ├─► Rotated on each use (one-time use)                        │
│  └─► Revocable server-side                                     │
│                                                                 │
│  ID Token (OIDC): Same as access token                          │
│  ├─► Contains user identity claims                             │
│  ├─► Validated once at login                                   │
│  └─► Not sent with API requests                                │
│                                                                 │
│  Timeline Example:                                              │
│  ─────────────────────────────────────────────────────────────  │
│  0min    15min   30min   45min   ...   7days                   │
│  │        │       │       │            │                       │
│  │ AT1    │ AT2   │ AT3   │ AT4        │                       │
│  └────────┴───────┴───────┴────────────┘                       │
│  └──────────── Refresh Token ──────────┘                       │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

Token Revocation Strategies

Strategy 1: Token Blacklist

const redis = require('redis');
const client = redis.createClient();

// Revoke a token
async function revokeToken(jti, exp) {
  const ttl = exp - Math.floor(Date.now() / 1000);
  if (ttl > 0) {
    await client.setEx(`blacklist:${jti}`, ttl, '1');
  }
}

// Check if token is revoked
async function isRevoked(jti) {
  const result = await client.get(`blacklist:${jti}`);
  return result === '1';
}

// Middleware
async function validateToken(req, res, next) {
  const decoded = jwt.verify(req.token, secret);

  if (await isRevoked(decoded.jti)) {
    return res.status(401).json({ error: 'Token revoked' });
  }

  req.user = decoded;
  next();
}

Strategy 2: Token Versioning

// User record includes token version
const user = {
  id: '12345',
  email: '[email protected]',
  tokenVersion: 3  // Increment to invalidate all tokens
};

// Include version in token
const token = jwt.sign({
  sub: user.id,
  tokenVersion: user.tokenVersion
}, secret);

// Validate version matches
async function validateToken(decoded) {
  const user = await getUser(decoded.sub);
  if (decoded.tokenVersion !== user.tokenVersion) {
    throw new Error('Token version mismatch - please re-authenticate');
  }
}

// Revoke all tokens for a user
async function revokeAllTokens(userId) {
  await db.query(
    'UPDATE users SET token_version = token_version + 1 WHERE id = $1',
    [userId]
  );
}

Security Checklist

Token Generation

  • Use strong signing algorithm (RS256, ES256, or HS256 with strong secret)
  • Generate cryptographically random secrets (256+ bits)
  • Include jti (unique ID) for revocation support
  • Set appropriate expiration (exp)
  • Include issuer (iss) and audience (aud)
  • Minimize payload size and sensitive data

Token Validation

  • Explicitly specify expected algorithm (never trust alg header)
  • Validate signature before trusting claims
  • Check expiration (exp) with clock tolerance
  • Validate issuer (iss) matches expected value
  • Validate audience (aud) includes your service
  • Validate not-before (nbf) if present

Token Storage

  • Never store in localStorage/sessionStorage
  • Use HttpOnly, Secure, SameSite cookies for web
  • Use platform secure storage for mobile
  • Keep access tokens in memory for SPAs
  • Implement BFF pattern for sensitive applications

Token Lifecycle

  • Use short-lived access tokens (5-15 min)
  • Implement refresh token rotation
  • Support token revocation mechanism
  • Clear tokens on logout
  • Handle token refresh failures gracefully

Tools for JWT Security

  • JWT Decoder - Inspect token structure (never use with production tokens online)
  • jwt.io - Debugger for development tokens
  • jose - Comprehensive JWT library for Node.js
  • PyJWT - JWT library for Python

Next Steps

Need Expert IT & Security Guidance?

Our team is ready to help protect and optimize your business technology infrastructure.