Kirito
The PERN Stack Authentication That Accepted Any Password (For 3 Days)
A typo in bcrypt.compare made it return true for any password. 1,200 user accounts were accessed without authorization before we caught it.
The PERN Stack Authentication That Accepted Any Password (For 3 Days)
"Someone logged into my account from another country."
Support tickets started pouring in. Users reported unauthorized access, changed passwords, and mysterious orders.
I checked the logs: logins from IPs all over the world, all succeeding.
Our authentication was completely broken.
The Setup
PERN stack with bcrypt for password hashing:
// authController.js const bcrypt = require('bcrypt'); const login = async (req, res) => { const { email, password } = req.body; const user = await db.query('SELECT * FROM users WHERE email = $1', [email]); if (user.rows.length === 0) { return res.status(401).json({ error: 'Invalid credentials' }); } // THE BUG const isValid = bcrypt.compare(password, user.rows[0].password_hash); if (isValid) { // Generate JWT const token = jwt.sign({ userId: user.rows[0].id }, process.env.JWT_SECRET); res.json({ token }); } else { res.status(401).json({ error: 'Invalid credentials' }); } };
The Bug
Look closely at this line:
const isValid = bcrypt.compare(password, user.rows[0].password_hash);
bcrypt.compare is asynchronous. It returns a Promise, not a boolean.
But I forgot the await.
So isValid became a Promise object. In JavaScript, if (isValid) is truthy (any object is truthy).
Therefore, every login attempt succeeded, regardless of password.
The Aftermath
For 3 days, attackers had been brute-forcing email addresses and gaining access with any password.
We found:
- 1,247 accounts accessed without authorization
- 89 fraudulent orders placed
- 24 accounts had their email changed (lockout)
- 3 accounts had payment methods added
The first unauthorized login was 2 hours after deployment.
The Emergency Response
1. Immediate fix
// Add await const isValid = await bcrypt.compare(password, user.rows[0].password_hash);
2. Force logout all users
-- Invalidate all sessions UPDATE users SET jwt_version = jwt_version + 1; -- Or delete all refresh tokens DELETE FROM refresh_tokens;
3. Require password reset
UPDATE users SET password_reset_required = true;
4. Audit all account activity
SELECT * FROM audit_log WHERE action IN ('login', 'order_created', 'payment_method_added') AND created_at > '2024-01-15 10:00:00' ORDER BY created_at;
5. Notify affected users
We sent emails to every user who logged in during that window, explaining the breach and forcing password reset.
The Prevention
1. Use TypeScript
// TypeScript would have caught this const isValid: boolean = bcrypt.compare(password, hash); // Error: Type 'Promise<boolean>' is not assignable to type 'boolean'
2. ESLint rule: no-misused-promises
{ "rules": { "@typescript-eslint/no-misused-promises": "error" } }
3. Wrap bcrypt in a utility with proper error handling
// utils/password.js const comparePassword = async (plain, hash) => { if (!plain || !hash) return false; try { return await bcrypt.compare(plain, hash); } catch (error) { console.error('bcrypt compare error:', error); return false; } };
4. Add integration test for auth
describe('Login', () => { it('should reject wrong password', async () => { const res = await request(app) .post('/login') .send({ email: 'test@example.com', password: 'wrong' }); expect(res.status).toBe(401); }); it('should reject invalid password format', async () => { // This would have caught the Promise bug because the test would fail }); });
5. Add logging for auth failures (and successes to detect anomalies)
if (!isValid) { logger.warn(`Failed login attempt for $ {email} from $ {req.ip}`); } // Also log successes for audit logger.info(`Successful login for $ {email} from $ {req.ip}`);
The Lessons
Async/await discipline: never forget await on async functions.
Use tools (TypeScript, ESLint) that catch these mistakes.
Test authentication edge cases – including wrong passwords.
Monitor for unusual login patterns (same user from multiple IPs).
Commands to Audit After Similar Bug
Find all async calls without await
# Grep for bcrypt.compare without await grep -r "bcrypt.compare" --include="*.js" | grep -v "await"
Check login logs for anomalies
# Show IPs with many successful logins to different users grep "Successful login" auth.log | awk '{print $NF}' | sort | uniq -c | sort -rn | head -10
What I Learned
That missing 'await' cost us:
- 3 days of security breach
- 1,200 compromised accounts
- Legal liability (we had to report to GDPR authorities)
- $15,000 in fraudulent orders (we reimbursed)
- 2 engineers working 48 hours straight on incident response
Now we have a pre-commit hook that warns on any 'bcrypt.compare' without 'await'. And we use TypeScript everywhere.
Authentication is not the place for shortcuts.