Authentication Best Practices

Chapter 2 · Practice 2 of 4

Do the same work whether or not the account exists

From The login flow and brute force protection

Why it matters

Even with identical messages, a login can leak which accounts exist through response time. A common pattern looks up the user, and if there is none, returns immediately. Because password hashing is deliberately slow, the "user exists" path takes noticeably longer than the "no such user" path, and OWASP calls out this quick exit as an enumeration risk.

The fix is to run the hash comparison every time. When the user is not found, verify the submitted password against a dummy hash created with the same parameters, then return the same failure.

Look beyond hashing too. OWASP notes that business logic, such as sending a "new sign-in" email only for real accounts, can create timing gaps of several seconds. Move work like that onto a background queue so the HTTP response does not wait for it.

To check, time a few hundred failed logins for a real and a fake email from the same network. The two distributions should overlap.

Example

TypeScript
const DUMMY_HASH = await hashPassword('dummy-password-for-timing-only');

export async function login(email: string, password: string) {
  const user = await db.user.findUnique({ where: { email: email.toLowerCase() } });
  const { ok } = await verifyPassword(user?.passwordHash ?? DUMMY_HASH, password);
  if (!user || !ok || user.disabled) {
    await recordFailure(email);
    return { error: 'Invalid email or password' };
  }
  return startSession(user);
}