PERN• 5 MIN READ
Kirito

Kirito

6/10/20265 min read

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.