How we discovered and fixed critical security flaws that most authentication systems overlook
Introduction
Authentication is the foundation of application security.
Yet, many developers even experienced ones overlook subtle vulnerabilities that can expose user data, enable account enumeration, or allow attackers to bypass rate limiting.
Over the past few months, I've been working on hardening an authentication service, and what I discovered shocked me: three critical vulnerabilities that are surprisingly common in production systems.
In this article, I'll walk you through these vulnerabilities, explain why they're dangerous, and show you how to fix them. Whether you're building your own auth system or evaluating third-party solutions, understanding these issues is crucial.
Vulnerability #1: Timing Attacks on Password Verification
The Problem
Imagine an attacker trying to guess a user's password.
They send login requests with different passwords and measure the response time.
If the server takes longer to respond when the username exists (even with a wrong password), the attacker knows the account exists. This is called a timing attack.
Here's what was happening in our code:
The vulnerability: When a user doesn't exist, the function returns immediately. When a user exists but the password is wrong, it performs the expensive password verification first.
This timing difference leaks information.
Why It Matters
Account Enumeration: Attackers can determine which email addresses are registered
User Privacy: Violates privacy by revealing account existence
Targeted Attacks: Attackers can focus on known accounts
The Fix
We implemented constant-time verification by always performing password verification, even for non-existent users:
// SECURE CODE
const user = await findUserByEmail(email);
const DUMMY_HASH = '$argon2id$v=19$m=65536,t=3,p=4$...'; // Pre-computed hash
// Always perform both verifications in parallel
const [, realResult] = await Promise.all([
verifyPassword(DUMMY_HASH, DUMMY_PASSWORD), // Dummy verification (always fails)
user && user.passwordHash
? verifyPassword(user.passwordHash, input.password)
: verifyPassword(DUMMY_HASH, DUMMY_PASSWORD), // Dummy if no user
]);
if (!user || !user.passwordHash || !realResult) {
throw new AuthError('Invalid credentials');
}
Key improvements:
Both paths take similar time (constant-time execution)
No information leakage about user existence
Parallel execution maintains performance
Vulnerability #2: IP Spoofing in Rate Limiting
The Problem
Rate limiting is essential for preventing brute-force attacks. However, many implementations trust the X-Forwarded-For header directly, which can be easily spoofed by attackers.
// VULNERABLE CODE
const ip = req.headers['x-forwarded-for']?.split(',')[0] || req.ip;
// Use IP for rate limiting
The vulnerability: Attackers can send fake X-Forwarded-For headers to bypass rate limits or frame innocent users.
Why It Matters
Bypass Rate Limits: Attackers can make unlimited requests
IP Spoofing: Frame legitimate users by using their IPs
DDoS Amplification: Distribute attacks across multiple fake IPs
The Fix
We fixed this by relying on Express's built-in req.ip, which respects the trust proxy setting:
// SECURE CODE
// In app.ts - configure trust proxy properly
if (env.nodeEnv === 'production') {
app.set('trust proxy', true); // Trust reverse proxy
} else if (process.env.TRUST_PROXY) {
app.set('trust proxy', process.env.TRUST_PROXY);
}
// In rate limiter
keyGenerator: (req) => {
// req.ip respects 'trust proxy' setting
// Express validates X-Forwarded-For when trust proxy is configured
return req.ip || 'unknown';
}
Key improvements:
Express validates proxy headers when trust proxy is set
No manual header parsing (which can be spoofed)
Proper configuration for production deployments
Vulnerability #3: Race Condition in OTP Verification
The Problem
When verifying one-time passwords (OTPs), there's a critical window between checking if an OTP is valid and marking it as used.
An attacker can exploit this with concurrent requests.
// VULNERABLE CODE (TOCTOU - Time of Check, Time of Use)
const otp = await findOTP(userId, code);
if (!otp || otp.used || otp.expiresAt < new Date()) {
throw new AuthError('Invalid OTP');
}
// ⚠️ RACE CONDITION WINDOW: Another request could verify the same OTP here
await markOTPAsUsed(otp.id);
The vulnerability: Two simultaneous requests can both verify the same OTP before either marks it as used.
Why It Matters
OTP Reuse: Attackers can use the same OTP multiple times
Security Bypass: Defeats the purpose of one-time passwords
Account Takeover: Multiple login sessions from a single OTP
The Fix
We used an atomic database operation to verify and mark as used in a single transaction:
// SECURE CODE
const result = await prisma.emailOTP.updateMany({
where: {
userId: user.id,
code,
used: false,
expiresAt: { gt: new Date() }, // Check expiry DB-side
},
data: {
used: true, // Atomic update
},
});
if (result.count === 0) {
throw new AuthError('Invalid or expired OTP code');
}
Key improvements:
Atomic operation (verify + mark as used in one query)
Database-level expiry check
No race condition window
Only one request can succeed per OTP
Bonus: Distributed Rate Limiting with Redis
While fixing these vulnerabilities, we also improved our rate limiting architecture by moving from in-memory to Redis-backed distributed rate limiting.
Why This Matters
Horizontal Scaling: Rate limits work across multiple server instances
Consistency: Same limits apply regardless of which server handles the request
Resilience: Graceful fallback to memory store if Redis is unavailable
// Redis-backed rate limiting with fallback
const redisClient = new Redis({
host: env.redis.host,
port: env.redis.port,
password: env.redis.password,
retryStrategy: (times) => Math.min(times * 50, 3000),
});
redisClient.on('error', (error) => {
logger.warn('Redis connection error, falling back to memory store');
// express-rate-limit automatically falls back to memory store
});
The Impact: Before vs. After
Before
❌ Timing attacks could enumerate user accounts
❌ Rate limiting could be bypassed via IP spoofing
❌ OTPs could be reused through race conditions
❌ Rate limiting only worked per-server instance
After
✅ Constant-time password verification (no information leakage)
✅ Proper IP handling with Express trust proxy
✅ Atomic OTP verification (no race conditions)
✅ Distributed rate limiting with Redis
Best Practices for Authentication Security
Based on our experience, here are the key principles every authentication system should follow:
Constant-Time Operations
Always ensure security-critical operations take the same time regardless of input. Use dummy operations when needed.Never Trust Client Headers Directly
Always validate proxy headers through your framework's built-in mechanisms (like Express's trust proxy).Use Atomic Database Operations
For operations that check and modify state (like OTP verification), use atomic database operations to prevent race conditions.Distributed Rate Limiting
Use Redis or similar for rate limiting in distributed systems.
Always have a fallback mechanism.Comprehensive Testing
Write security-focused tests that specifically check for timing attacks, race conditions, and edge cases.
Testing Your Authentication System
We implemented comprehensive security tests to ensure these vulnerabilities stay fixed:
describe('Timing Attack Protection', () => {
it('should have constant-time execution for non-existent users', async () => {
const time1 = await measureLoginTime('nonexistent1@example.com');
const time2 = await measureLoginTime('nonexistent2@example.com');
// Times should be similar (within 50ms tolerance)
expect(Math.abs(time1 - time2)).toBeLessThan(50);
});
});
Key test categories:
Timing attack protection
Rate limiting effectiveness
Race condition prevention
Input validation
Conclusion: Building Secure Authentication
Authentication security is a continuous process.
The vulnerabilities we discovered are subtle but critical, and they're more common than you might think. By understanding these issues and implementing proper fixes, you can significantly improve your application's security posture.
Key Takeaways
Timing attacks are real - Always use constant-time operations for security-critical code
Never trust client headers - Use framework mechanisms for proxy validation
Race conditions matter - Use atomic database operations for state changes
Test security explicitly - Don't just test happy paths; test attack scenarios
Distributed systems need distributed rate limiting - In-memory limits don't scale
About Rugi Auth
While working on these security improvements, I was building Rugi Auth—a centralized authentication service designed with security as a first-class concern. It includes:
✅ RS256 JWT signing with RSA keys
✅ Argon2id password hashing (memory-hard, timing-attack resistant)
✅ Redis-backed distributed rate limiting with automatic fallback
✅ Comprehensive security protections against timing attacks, IP spoofing, and race conditions
✅ Extensive test coverage including security-focused tests
✅ Multi-app role management for complex authorization needs
If you're building authentication for your applications, I'd love for you to check it out. You can get started with:
npx rugi-auth init my-auth-server
The project is open-source, and all the security improvements discussed in this article are implemented and tested. You can find it on GitHub and install it via npm.
Resources
OWASP Authentication Cheat Sheet
Timing Attack Prevention
Express Trust Proxy Documentation
Redis Rate Limiting Best Practices
Have you encountered these vulnerabilities in your projects? What security measures do you implement in your authentication systems? Share your thoughts in the comments below!

Top comments (0)