Skip to main content

JWT Best Practices and Common Pitfalls

Ryan Dahlberg
Ryan Dahlberg
September 15, 2025 9 min read
Share:
JWT Best Practices and Common Pitfalls

Introduction

JSON Web Tokens (JWTs) have become the de facto standard for stateless authentication in modern web applications. However, their widespread adoption has also revealed numerous security pitfalls that developers frequently encounter.

This comprehensive guide explores implementing JWT authentication securely, covering signing algorithms, token storage, expiration strategies, and common vulnerabilities to avoid in production systems.

Understanding JWT Structure

Token Anatomy

JWTs consist of three parts separated by dots:

// JWT Structure: header.payload.signature
const token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c';

// Decoded header
{
  "alg": "HS256",
  "typ": "JWT"
}

// Decoded payload
{
  "sub": "1234567890",
  "name": "John Doe",
  "iat": 1516239022,
  "exp": 1516242622
}

Secure Token Generation

const jwt = require('jsonwebtoken');
const crypto = require('crypto');

class JWTService {
  constructor(config) {
    this.accessTokenSecret = config.accessTokenSecret;
    this.refreshTokenSecret = config.refreshTokenSecret;
    this.accessTokenExpiry = '15m';
    this.refreshTokenExpiry = '7d';
  }

  generateTokenPair(userId, userData) {
    // Generate unique token ID
    const tokenId = crypto.randomBytes(16).toString('hex');

    const accessToken = jwt.sign(
      {
        sub: userId,
        jti: tokenId,
        type: 'access',
        ...userData
      },
      this.accessTokenSecret,
      {
        algorithm: 'RS256', // Use asymmetric algorithm
        expiresIn: this.accessTokenExpiry,
        issuer: 'your-app-name',
        audience: 'your-app-api'
      }
    );

    const refreshToken = jwt.sign(
      {
        sub: userId,
        jti: tokenId,
        type: 'refresh'
      },
      this.refreshTokenSecret,
      {
        algorithm: 'RS256',
        expiresIn: this.refreshTokenExpiry,
        issuer: 'your-app-name'
      }
    );

    return { accessToken, refreshToken, tokenId };
  }
}

Pitfall #1: Using Weak Signing Algorithms

The Problem

Using none algorithm or weak symmetric keys compromises token integrity:

// DANGEROUS: Never use 'none' algorithm
const unsafeToken = jwt.sign(payload, '', { algorithm: 'none' });

// WEAK: Short symmetric keys are easily brute-forced
const weakSecret = 'secret123';

The Solution

Always use strong asymmetric algorithms with proper key management:

const fs = require('fs');

class SecureJWTService {
  constructor() {
    // Load RSA keys from secure storage
    this.privateKey = fs.readFileSync('./keys/private.pem', 'utf8');
    this.publicKey = fs.readFileSync('./keys/public.pem', 'utf8');
  }

  signToken(payload) {
    return jwt.sign(payload, this.privateKey, {
      algorithm: 'RS256', // RSA with SHA-256
      keyid: 'key-2025-01'
    });
  }

  verifyToken(token) {
    try {
      return jwt.verify(token, this.publicKey, {
        algorithms: ['RS256'], // Whitelist allowed algorithms
        issuer: 'your-app-name'
      });
    } catch (error) {
      throw new Error(`Token verification failed: ${error.message}`);
    }
  }
}

Pitfall #2: Storing Sensitive Data in Tokens

The Problem

JWTs are encoded, not encrypted. Sensitive data in the payload is readable:

// DANGEROUS: Sensitive data exposed
const badToken = jwt.sign({
  userId: 123,
  email: 'user@example.com',
  password: 'hashed_password', // Never include this
  creditCard: '1234-5678-9012-3456', // Never include this
  ssn: '123-45-6789' // Never include this
}, secret);

The Solution

Only include necessary, non-sensitive claims:

function createSecurePayload(user) {
  return {
    sub: user.id, // Subject (user ID)
    jti: generateTokenId(), // Unique token ID
    iat: Math.floor(Date.now() / 1000), // Issued at
    exp: Math.floor(Date.now() / 1000) + (15 * 60), // Expires in 15 min
    roles: user.roles, // User roles
    permissions: user.permissions.map(p => p.name), // Permission names only
    // Never include passwords, sensitive PII, or financial data
  };
}

Pitfall #3: Improper Token Storage

The Problem

Storing tokens in localStorage makes them vulnerable to XSS attacks:

// VULNERABLE to XSS
localStorage.setItem('accessToken', token);

// Also vulnerable
sessionStorage.setItem('accessToken', token);

The Solution

Use httpOnly cookies for enhanced security:

// Express.js example
app.post('/login', async (req, res) => {
  const { accessToken, refreshToken } = await authenticateUser(req.body);

  // Store refresh token in httpOnly cookie
  res.cookie('refreshToken', refreshToken, {
    httpOnly: true, // Not accessible via JavaScript
    secure: true, // Only sent over HTTPS
    sameSite: 'strict', // CSRF protection
    maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
    path: '/api/auth/refresh'
  });

  // Send access token in response body (store in memory)
  res.json({
    accessToken,
    expiresIn: 900 // 15 minutes
  });
});

Pitfall #4: Missing Token Revocation

The Problem

JWTs are stateless, making them difficult to revoke before expiration:

// User logs out but token remains valid until expiration
app.post('/logout', (req, res) => {
  // Token still works for 15 minutes!
  res.json({ message: 'Logged out' });
});

The Solution

Implement token blacklisting with Redis:

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

class TokenBlacklist {
  async revokeToken(token) {
    const decoded = jwt.decode(token);
    const ttl = decoded.exp - Math.floor(Date.now() / 1000);

    if (ttl > 0) {
      await client.setEx(
        `blacklist:${decoded.jti}`,
        ttl,
        'revoked'
      );
    }
  }

  async isRevoked(token) {
    const decoded = jwt.decode(token);
    const result = await client.get(`blacklist:${decoded.jti}`);
    return result === 'revoked';
  }
}

// Middleware to check blacklist
async function checkBlacklist(req, res, next) {
  const token = req.headers.authorization?.split(' ')[1];

  if (!token) {
    return res.status(401).json({ error: 'No token provided' });
  }

  const blacklist = new TokenBlacklist();
  if (await blacklist.isRevoked(token)) {
    return res.status(401).json({ error: 'Token has been revoked' });
  }

  next();
}

Pitfall #5: Inadequate Expiration Times

The Problem

Long-lived access tokens increase security risk:

// DANGEROUS: Access token valid for 30 days
const token = jwt.sign(payload, secret, { expiresIn: '30d' });

The Solution

Use short-lived access tokens with refresh token rotation:

class TokenManager {
  issueTokens(userId) {
    const accessToken = jwt.sign(
      { sub: userId, type: 'access' },
      accessSecret,
      { expiresIn: '15m' } // Short-lived
    );

    const refreshToken = jwt.sign(
      { sub: userId, type: 'refresh' },
      refreshSecret,
      { expiresIn: '7d' } // Longer-lived
    );

    return { accessToken, refreshToken };
  }

  async refreshAccessToken(refreshToken) {
    try {
      const decoded = jwt.verify(refreshToken, refreshSecret);

      if (decoded.type !== 'refresh') {
        throw new Error('Invalid token type');
      }

      // Generate new token pair
      const newTokens = this.issueTokens(decoded.sub);

      // Rotate refresh token
      await this.revokeToken(refreshToken);

      return newTokens;
    } catch (error) {
      throw new Error('Refresh token invalid or expired');
    }
  }
}

Pitfall #6: Missing Audience and Issuer Validation

The Problem

Without audience and issuer validation, tokens can be misused across services:

// INCOMPLETE: No audience/issuer validation
jwt.verify(token, publicKey);

The Solution

Always validate audience and issuer claims:

class JWTValidator {
  constructor(config) {
    this.publicKey = config.publicKey;
    this.expectedIssuer = config.issuer;
    this.expectedAudience = config.audience;
  }

  validateToken(token) {
    try {
      return jwt.verify(token, this.publicKey, {
        algorithms: ['RS256'],
        issuer: this.expectedIssuer,
        audience: this.expectedAudience,
        clockTolerance: 30 // 30 seconds clock skew tolerance
      });
    } catch (error) {
      if (error.name === 'TokenExpiredError') {
        throw new Error('Token has expired');
      } else if (error.name === 'JsonWebTokenError') {
        throw new Error('Token is invalid');
      } else if (error.name === 'NotBeforeError') {
        throw new Error('Token not yet valid');
      }
      throw error;
    }
  }
}

// Usage
const validator = new JWTValidator({
  publicKey: process.env.JWT_PUBLIC_KEY,
  issuer: 'https://auth.yourapp.com',
  audience: 'https://api.yourapp.com'
});

Complete Implementation Example

Express.js Authentication Flow

const express = require('express');
const app = express();

// Secure JWT service with all best practices
class SecureAuthService {
  constructor() {
    this.jwtService = new SecureJWTService();
    this.tokenManager = new TokenManager();
    this.blacklist = new TokenBlacklist();
  }

  async authenticate(req, res, next) {
    try {
      const token = req.headers.authorization?.split(' ')[1];

      if (!token) {
        return res.status(401).json({ error: 'No token provided' });
      }

      // Check blacklist
      if (await this.blacklist.isRevoked(token)) {
        return res.status(401).json({ error: 'Token revoked' });
      }

      // Verify token
      const decoded = this.jwtService.verifyToken(token);

      // Attach user info to request
      req.user = {
        id: decoded.sub,
        roles: decoded.roles,
        permissions: decoded.permissions
      };

      next();
    } catch (error) {
      res.status(401).json({ error: 'Invalid token' });
    }
  }
}

// Instantiate service
const authService = new SecureAuthService();

// Protected route
app.get('/api/protected',
  authService.authenticate.bind(authService),
  (req, res) => {
    res.json({ data: 'Protected data', user: req.user });
  }
);

// Logout endpoint with token revocation
app.post('/api/logout',
  authService.authenticate.bind(authService),
  async (req, res) => {
    const token = req.headers.authorization?.split(' ')[1];
    await authService.blacklist.revokeToken(token);
    res.json({ message: 'Logged out successfully' });
  }
);

Security Best Practices Checklist

Essential practices for JWT security:

  1. Algorithm Security: Use RS256 or ES256, never HS256 with weak secrets or ‘none’
  2. Token Lifetime: Keep access tokens short-lived (5-15 minutes)
  3. Secure Storage: Use httpOnly cookies for refresh tokens, memory for access tokens
  4. Revocation Strategy: Implement token blacklisting with Redis or similar
  5. Claim Validation: Always validate iss, aud, exp, and nbf claims
  6. Sensitive Data: Never store passwords, PII, or financial data in tokens
  7. HTTPS Only: Always transmit tokens over HTTPS
  8. CSRF Protection: Use SameSite cookies and implement CSRF tokens
  9. Refresh Token Rotation: Generate new refresh token on each use
  10. Key Management: Rotate signing keys regularly, store securely

Testing JWT Implementation

const request = require('supertest');

describe('JWT Authentication', () => {
  let app;
  let authService;

  beforeEach(() => {
    app = createApp();
    authService = new SecureAuthService();
  });

  it('should reject requests without token', async () => {
    const response = await request(app)
      .get('/api/protected')
      .expect(401);

    expect(response.body.error).toBe('No token provided');
  });

  it('should reject expired tokens', async () => {
    const expiredToken = jwt.sign(
      { sub: '123' },
      privateKey,
      { expiresIn: '-1h' }
    );

    const response = await request(app)
      .get('/api/protected')
      .set('Authorization', `Bearer ${expiredToken}`)
      .expect(401);

    expect(response.body.error).toBe('Invalid token');
  });

  it('should reject revoked tokens', async () => {
    const { accessToken } = authService.tokenManager.issueTokens('123');
    await authService.blacklist.revokeToken(accessToken);

    const response = await request(app)
      .get('/api/protected')
      .set('Authorization', `Bearer ${accessToken}`)
      .expect(401);

    expect(response.body.error).toBe('Token revoked');
  });

  it('should accept valid tokens', async () => {
    const { accessToken } = authService.tokenManager.issueTokens('123');

    const response = await request(app)
      .get('/api/protected')
      .set('Authorization', `Bearer ${accessToken}`)
      .expect(200);

    expect(response.body.user.id).toBe('123');
  });
});

Conclusion

Implementing JWT authentication securely requires attention to numerous details and potential pitfalls. By following the best practices outlined in this guide, you can avoid common vulnerabilities and build a robust authentication system.

Key takeaways:

  • Always use strong asymmetric algorithms like RS256
  • Keep access tokens short-lived and implement refresh token rotation
  • Never store sensitive data in JWT payloads
  • Implement token revocation with blacklisting
  • Validate all token claims including issuer and audience
  • Store tokens securely using httpOnly cookies
  • Always transmit tokens over HTTPS
  • Regularly rotate signing keys

Remember that JWT security is not a one-time implementation but requires ongoing vigilance and updates as new vulnerabilities are discovered. Stay informed about the latest security advisories and regularly audit your authentication implementation.

By treating JWT security as a critical component of your application architecture and following these best practices, you can provide your users with secure, scalable authentication while minimizing the risk of token-based attacks.

#Authentication #JWT #Web Security #API Security