The Problem with JWT Authentication

JWT authentication might be causing more problems than it solves. Learn about better alternatives for secure authentication.

JWT (JSON Web Tokens) has become the default choice for web authentication. But it might be causing more problems than it solves. Here's why, and what to use instead.

The Common Pattern

// Looks simple at first...
class AuthService {
  async login(email: string, password: string): Promise<string> {
    const user = await this.validateUser(email, password);
    
    return jwt.sign(
      { userId: user.id, role: user.role },
      process.env.JWT_SECRET,
      { expiresIn: '24h' }
    );
  }

  verifyToken(token: string) {
    return jwt.verify(token, process.env.JWT_SECRET);
  }
}

The Problems

1. Token Revocation

// ❌ BAD: Can't revoke active tokens
class AuthController {
  async logout(req: Request) {
    // Token is still valid until expiration
    // Nothing we can do about it
    return { message: 'Logged out' };
  }

  async changePassword(req: Request) {
    await this.userService.updatePassword(
      req.user.id,
      req.body.newPassword
    );
    // All existing tokens are still valid!
  }
}

2. Token Size

// ❌ BAD: Bloated tokens
interface UserToken {
  id: string;
  email: string;
  name: string;
  role: string;
  permissions: string[];
  organizationId: string;
  teams: string[];
  // Token gets larger with more data
  // Sent with EVERY request
}

3. Security Vulnerabilities

// ❌ BAD: Common security issues
class AuthMiddleware {
  async authenticate(req: Request) {
    const token = req.headers.authorization?.split(' ')[1];
    
    try {
      // No way to check if token was stolen
      const payload = jwt.verify(token, JWT_SECRET);
      req.user = payload;
    } catch (error) {
      // Token might be valid but expired
      // Or tampered with
      // Or using old secret
      throw new UnauthorizedError();
    }
  }
}

Better Approaches

1. Session Tokens

// ✅ BETTER: Server-side sessions
class SessionManager {
  constructor(
    private redis: Redis,
    private options: SessionOptions
  ) {}

  async createSession(user: User): Promise<string> {
    const sessionId = generateSecureId();
    const session = {
      userId: user.id,
      createdAt: new Date(),
      expiresAt: addHours(new Date(), 24)
    };

    await this.redis.setex(
      `session:${sessionId}`,
      86400, // 24 hours
      JSON.stringify(session)
    );

    return sessionId;
  }

  async getSession(sessionId: string): Promise<Session | null> {
    const data = await this.redis.get(`session:${sessionId}`);
    if (!data) return null;

    const session = JSON.parse(data);
    if (new Date(session.expiresAt) < new Date()) {
      await this.redis.del(`session:${sessionId}`);
      return null;
    }

    return session;
  }

  async revokeSession(sessionId: string): Promise<void> {
    await this.redis.del(`session:${sessionId}`);
  }
}

2. Refresh Token Pattern

// ✅ BETTER: Short-lived access tokens with refresh tokens
class TokenService {
  async createTokenPair(user: User): Promise<TokenPair> {
    const accessToken = await this.createAccessToken(user);
    const refreshToken = await this.createRefreshToken(user);

    await this.redis.setex(
      `refresh:${refreshToken.id}`,
      30 * 86400, // 30 days
      JSON.stringify({
        userId: user.id,
        tokenId: refreshToken.id,
        family: refreshToken.family
      })
    );

    return {
      accessToken: accessToken.token,
      refreshToken: refreshToken.token,
      expiresIn: 900 // 15 minutes
    };
  }

  async refreshTokens(refreshToken: string): Promise<TokenPair> {
    const decoded = await this.verifyRefreshToken(refreshToken);
    const stored = await this.getStoredRefreshToken(decoded.id);

    // Detect token reuse
    if (!stored || stored.family !== decoded.family) {
      await this.revokeTokenFamily(decoded.family);
      throw new SecurityError('Token reuse detected');
    }

    // Create new token pair
    const user = await this.userService.findById(stored.userId);
    return this.createTokenPair(user);
  }
}
// ✅ BETTER: HTTP-only cookies with CSRF protection
class SecureSessionManager {
  async createSession(res: Response, user: User): Promise<void> {
    const sessionId = generateSecureId();
    const csrfToken = generateSecureId();

    // Store session
    await this.redis.setex(
      `session:${sessionId}`,
      86400,
      JSON.stringify({
        userId: user.id,
        csrfToken
      })
    );

    // Set cookies
    res.cookie('sessionId', sessionId, {
      httpOnly: true,
      secure: true,
      sameSite: 'strict',
      maxAge: 86400000
    });

    res.cookie('csrf', csrfToken, {
      secure: true,
      sameSite: 'strict',
      maxAge: 86400000
    });
  }

  async validateRequest(req: Request): Promise<User> {
    const sessionId = req.cookies.sessionId;
    const csrfToken = req.cookies.csrf;
    const headerToken = req.headers['x-csrf-token'];

    if (!sessionId || !csrfToken || csrfToken !== headerToken) {
      throw new SecurityError('Invalid session');
    }

    const session = await this.getSession(sessionId);
    if (!session || session.csrfToken !== csrfToken) {
      throw new SecurityError('Invalid session');
    }

    return this.userService.findById(session.userId);
  }
}

Advanced Security Patterns

1. Device Fingerprinting

// ✅ BETTER: Track session contexts
class SessionContext {
  constructor(
    private readonly redis: Redis,
    private readonly detector: DeviceDetector
  ) {}

  async validateContext(
    sessionId: string,
    req: Request
  ): Promise<boolean> {
    const fingerprint = this.generateFingerprint(req);
    const stored = await this.getStoredFingerprint(sessionId);

    if (!stored) {
      await this.storeFingerprint(sessionId, fingerprint);
      return true;
    }

    const similarity = this.calculateSimilarity(
      stored,
      fingerprint
    );

    if (similarity < 0.8) {
      await this.notifySecurityAlert(sessionId, {
        stored,
        current: fingerprint
      });
      return false;
    }

    return true;
  }

  private generateFingerprint(req: Request): Fingerprint {
    return {
      userAgent: req.headers['user-agent'],
      ip: req.ip,
      acceptLanguage: req.headers['accept-language'],
      // Add more signals as needed
    };
  }
}

2. Rate Limiting

// ✅ BETTER: Protect authentication endpoints
class AuthRateLimiter {
  private readonly limits = {
    login: { points: 5, duration: 300 },    // 5 attempts per 5 min
    refresh: { points: 10, duration: 300 }, // 10 attempts per 5 min
    verify: { points: 30, duration: 60 }    // 30 attempts per min
  };

  async checkLimit(
    action: keyof typeof limits,
    identifier: string
  ): Promise<void> {
    const key = `ratelimit:${action}:${identifier}`;
    const limit = this.limits[action];

    const current = await this.redis.incr(key);
    if (current === 1) {
      await this.redis.expire(key, limit.duration);
    }

    if (current > limit.points) {
      throw new RateLimitError(action);
    }
  }
}

Best Practices

1. Security Headers

// ✅ BETTER: Comprehensive security headers
const securityMiddleware = (req: Request, res: Response) => {
  res.setHeader('Strict-Transport-Security', 'max-age=31536000');
  res.setHeader('X-Frame-Options', 'DENY');
  res.setHeader('X-Content-Type-Options', 'nosniff');
  res.setHeader('Content-Security-Policy', [
    "default-src 'self'",
    "script-src 'self'",
    "style-src 'self'",
    "img-src 'self' data:",
    "font-src 'self'",
    "frame-ancestors 'none'"
  ].join('; '));
};

2. Error Handling

// ✅ BETTER: Secure error responses
class AuthErrorHandler {
  handle(error: Error): ApiResponse {
    if (error instanceof TokenExpiredError) {
      return {
        code: 'TOKEN_EXPIRED',
        message: 'Please refresh your session'
      };
    }

    if (error instanceof SecurityError) {
      return {
        code: 'SECURITY_ERROR',
        message: 'Session invalidated'
      };
    }

    // Don't leak internal errors
    return {
      code: 'AUTH_ERROR',
      message: 'Authentication failed'
    };
  }
}

Conclusion

Instead of JWTs, consider:

  • Session tokens for web applications
  • Short-lived access tokens with refresh tokens
  • Secure cookie-based sessions
  • Server-side session management

Remember: Security is about trade-offs. Choose the right tool for your specific needs.