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);
}
}
3. Secure Cookie Sessions
// ✅ 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.