The Truth About JWT: When to Use It, When to Avoid It

Think JWT is always the answer? Learn about its security implications, best practices, and when traditional sessions might be a better choice.

JWT (JSON Web Tokens) isn't always the best authentication solution. Here's what nobody tells you about its security implications and alternatives.


Common Misconceptions

1. "JWTs are Always Secure"

// ❌ BAD: Unsafe JWT implementation
const createToken = (userId: string) => {
  return jwt.sign(
    { userId },
    'secret123', // Hardcoded secret
    { expiresIn: '1y' }  // Too long expiration
  );
};

// ✅ BETTER: Secure JWT implementation
const createToken = (userId: string) => {
  return jwt.sign(
    {
      userId,
      sessionId: crypto.randomUUID(),
      issuedAt: Date.now()
    },
    process.env.JWT_SECRET,
    {
      expiresIn: '15m',
      algorithm: 'HS256'
    }
  );
};

2. "JWTs Replace Sessions"

// ❌ BAD: Storing sensitive data in JWT
const token = jwt.sign({
  userId: user.id,
  role: user.role,
  permissions: user.permissions,
  email: user.email,
  // Don't store sensitive data in tokens!
}, secret);

// ✅ BETTER: Minimal JWT with session reference
const token = jwt.sign({
  sub: user.id,
  jti: sessionId,  // Session reference
  iat: Date.now()
}, secret);

Security Concerns

1. Token Invalidation

// ❌ BAD: No way to invalidate tokens
app.post('/logout', (req, res) => {
  // Client deletes token, but it's still valid!
  res.clearCookie('token');
  res.json({ message: 'Logged out' });
});

// ✅ BETTER: Token blacklisting with Redis
class TokenBlacklist {
  constructor(private redis: Redis) {}

  async invalidate(token: string) {
    const decoded = jwt.decode(token) as JwtPayload;
    const timeLeft = decoded.exp! - Date.now() / 1000;
    
    await this.redis.setex(
      `blacklist:${token}`,
      Math.ceil(timeLeft),
      '1'
    );
  }

  async isBlacklisted(token: string): Promise<boolean> {
    return await this.redis.exists(`blacklist:${token}`) === 1;
  }
}

2. XSS Protection

// ❌ BAD: Vulnerable to XSS
app.use(cookieParser());
app.get('/api/auth', (req, res) => {
  res.cookie('token', token); // No security options
});

// ✅ BETTER: Secure cookie settings
app.get('/api/auth', (req, res) => {
  res.cookie('token', token, {
    httpOnly: true,
    secure: true,
    sameSite: 'strict',
    path: '/api',
    maxAge: 900000 // 15 minutes
  });
});

Better Patterns

1. Refresh Token Flow

class AuthService {
  async createTokenPair(userId: string) {
    const accessToken = jwt.sign(
      { sub: userId },
      process.env.JWT_SECRET,
      { expiresIn: '15m' }
    );

    const refreshToken = crypto.randomBytes(40).toString('hex');
    
    await this.redis.setex(
      `refresh:${refreshToken}`,
      30 * 24 * 60 * 60, // 30 days
      userId
    );

    return { accessToken, refreshToken };
  }

  async refresh(refreshToken: string) {
    const userId = await this.redis.get(`refresh:${refreshToken}`);
    if (!userId) throw new Error('Invalid refresh token');

    // Rotate refresh token
    await this.redis.del(`refresh:${refreshToken}`);
    return this.createTokenPair(userId);
  }
}

2. Session-JWT Hybrid

class HybridAuth {
  async createSession(user: User) {
    const sessionId = crypto.randomUUID();
    
    // Store session data
    await this.redis.hset(`session:${sessionId}`, {
      userId: user.id,
      role: user.role,
      createdAt: Date.now()
    });

    // Create JWT with session reference
    const token = jwt.sign(
      { 
        sub: user.id,
        jti: sessionId
      },
      process.env.JWT_SECRET,
      { expiresIn: '15m' }
    );

    return token;
  }

  async validateToken(token: string) {
    const decoded = jwt.verify(token, process.env.JWT_SECRET);
    const session = await this.redis.hgetall(
      `session:${decoded.jti}`
    );

    if (!session) throw new Error('Invalid session');
    return session;
  }
}

Performance Considerations

1. Token Size

// ❌ BAD: Large tokens
const bloatedToken = jwt.sign({
  user: {
    id: 1,
    name: 'John',
    email: 'john@example.com',
    permissions: ['read', 'write', 'admin'],
    preferences: {
      theme: 'dark',
      language: 'en',
      notifications: true
    },
    metadata: {
      lastLogin: '2024-01-20T00:00:00Z',
      loginCount: 42
    }
  }
}, secret);

// ✅ BETTER: Minimal tokens
const efficientToken = jwt.sign({
  sub: '1',
  jti: 'session_id'
}, secret);

2. Caching Strategy

class TokenValidator {
  private cache: LRUCache<string, SessionData>;

  constructor() {
    this.cache = new LRUCache({
      max: 10000,
      maxAge: 15 * 60 * 1000 // 15 minutes
    });
  }

  async validateToken(token: string) {
    // Check cache first
    const cached = this.cache.get(token);
    if (cached) return cached;

    // Validate and decode token
    const decoded = jwt.verify(token, process.env.JWT_SECRET);
    
    // Get session data
    const session = await this.getSession(decoded.jti);
    
    // Cache result
    this.cache.set(token, session);
    
    return session;
  }
}

When to Use JWT

1. Good Use Cases

// ✅ GOOD: API-only services
class ApiAuthService {
  async createServiceToken(serviceId: string) {
    return jwt.sign(
      {
        sub: serviceId,
        type: 'service',
        scope: ['api:read']
      },
      process.env.JWT_SECRET,
      { expiresIn: '1h' }
    );
  }
}

// ✅ GOOD: Stateless microservices
class MicroserviceAuth {
  validateServiceToken(token: string) {
    const decoded = jwt.verify(token, process.env.JWT_SECRET);
    return decoded.type === 'service';
  }
}

2. Bad Use Cases

// ❌ BAD: User sessions with sensitive data
class UserSession {
  // Don't store sensitive data in JWT
  createUserToken(user: User) {
    return jwt.sign({
      sub: user.id,
      email: user.email,
      creditCard: user.paymentInfo,
      address: user.address
    }, secret);
  }
}

// ❌ BAD: Long-lived sessions
class PersistentAuth {
  // Don't use long-lived JWTs
  createPermanentToken(userId: string) {
    return jwt.sign(
      { sub: userId },
      secret,
      { expiresIn: '1y' }
    );
  }
}

Alternatives

1. Session-Based Auth

class SessionAuth {
  async createSession(user: User) {
    const sessionId = crypto.randomUUID();
    
    await this.redis.hset(`session:${sessionId}`, {
      userId: user.id,
      createdAt: Date.now(),
      userAgent: user.userAgent
    });

    return sessionId;
  }

  async validateSession(sessionId: string) {
    const session = await this.redis.hgetall(
      `session:${sessionId}`
    );
    
    if (!session) throw new Error('Invalid session');
    return session;
  }
}

2. API Keys

class ApiKeyAuth {
  async createApiKey(userId: string) {
    const apiKey = `key_${crypto.randomBytes(32).toString('hex')}`;
    const hashedKey = await bcrypt.hash(apiKey, 10);
    
    await this.db.apiKeys.create({
      userId,
      hashedKey,
      createdAt: new Date()
    });

    return apiKey;
  }

  async validateApiKey(apiKey: string) {
    const hashedKey = await bcrypt.hash(apiKey, 10);
    return await this.db.apiKeys.findOne({
      where: { hashedKey }
    });
  }
}

Conclusion

Use JWT when:

  • Building stateless APIs
  • Short-lived tokens needed
  • Service-to-service auth
  • Limited session data

Avoid JWT when:

  • Managing user sessions
  • Storing sensitive data
  • Need immediate invalidation
  • Long-lived tokens required

Remember: JWT is a tool, not a solution for every auth problem.