DEV Community

Cover image for Why Your Login Response Time Is a Security Leak
Ozoemena John
Ozoemena John

Posted on

Why Your Login Response Time Is a Security Leak

You did everything right. Your login endpoint returns a generic "invalid credentials" for both a wrong password and a nonexistent email. No information leaked — right?

Not quite. There's a channel most of us never think to close: how long the response took to come back.

The leak, made concrete

Here's roughly what a "correct-looking" login handler does:

const user = await db.findUserByEmail(email)

if (!user) {
  return { status: 'invalid-credentials' }
}

const isValid = await verifyPassword(password, user.passwordHash)

if (!isValid) {
  return { status: 'invalid-credentials' }
}
Enter fullscreen mode Exit fullscreen mode

Both failure paths return the exact same response shape. Looks airtight. But watch what actually happens on the wire:

  • Email doesn't exist: one database lookup, then an immediate return. Fast — a few milliseconds.
  • Email exists, password is wrong: the database lookup, plus a full password verification. If you're using bcrypt, scrypt, or Argon2 (and you should be), that step is deliberately slow — often 50–200ms, by design, to resist brute-forcing.

An attacker doesn't need to read your response body at all. They just need a stopwatch. Submit a candidate email with any password, measure the response time, and a gap of 100+ ms tells them the account exists — invisibly, at scale, across thousands of addresses, with zero errors logged that look unusual (they're all just "failed logins").

This is a real, well-known class of attack called a timing side-channel, and it's one of the easier ones to actually pull off, because HTTP response timing is something every client can measure for free.

The instinctive fix doesn't fully work

The first thing most people try:

if (!user) {
  // "compare against something" so the timing looks similar
  await bcrypt.compare(password, DUMMY_HASH)
  return { status: 'invalid-credentials' }
}
Enter fullscreen mode Exit fullscreen mode

Better! Now both branches do some expensive hashing work. But this only gets you closer, not equal — and "closer" is exactly the gap a patient attacker exploits. Real systems have jitter: network latency, GC pauses, database connection pool contention. A few milliseconds of consistent difference, averaged over a few hundred requests per candidate email, is still statistically detectable. You've raised the cost of the attack, not eliminated it.

There's also a second, easy-to-miss version of this same bug: inconsistent response shape, not just timing. If your registration endpoint returns { status: 'success', user: {...} } for a new email but { status: 'success-pending-verification' } (no user object) for one that already exists — congratulations, you've built a perfectly reliable, zero-timing-analysis-required account enumeration oracle. This is arguably worse than the timing leak, because it doesn't even require statistics — one request tells you everything.

What actually works: a response floor

The fix that closes this properly isn't "make the branches equally fast" (hard to guarantee) — it's "pad every branch up to a fixed minimum time," so the total response time is constant regardless of which internal path executed:

private async holdToFloor(start: number): Promise<void> {
  const elapsed = Date.now() - start
  const remaining = this.responseFloorMs - elapsed
  if (remaining > 0) {
    await new Promise((resolve) => setTimeout(resolve, remaining))
  }
}
Enter fullscreen mode Exit fullscreen mode

Wrap this around every exit path of the endpoint — success, wrong password, nonexistent user, even unexpected server errors — and the observable timing collapses to "at least responseFloorMs," full stop. It doesn't matter if the actual work took 3ms or 80ms; the client always waits until the floor. No amount of statistical averaging recovers a timing signal that was never there.

public async executePasswordStage(rawInput, secret, context, options) {
  const start = Date.now()

  try {
    const user = await this.adapter.findUserByEmail(email)

    if (!user || !user.passwordHash) {
      // still do the expensive comparison, so this path costs roughly
      // the same as the real one — belt AND suspenders with the floor
      await this.crypto.verifyPassword(password, STATIC_DUMMY_HASH)
      await this.holdToFloor(start)
      return { status: 'invalid-credentials' }
    }

    const isValid = await this.crypto.verifyPassword(password, user.passwordHash)

    if (!isValid) {
      await this.holdToFloor(start)
      return { status: 'invalid-credentials' }
    }

    // ...success path also goes through holdToFloor before returning
  } catch (err) {
    await this.holdToFloor(start)
    return { status: 'system-error', message: '...' }
  }
}
Enter fullscreen mode Exit fullscreen mode

Note the dummy-hash comparison is still there — the floor is defense in depth, not a replacement for doing real, comparable work on both paths. Belt and suspenders.

The response-shape fix, applied to registration

The same principle applies to what you return, not just when. For registration specifically, this means every branch — new email, existing email, even with enumeration protection explicitly disabled — has to converge to identical shapes:

if (existingUser) {
  if (this.protectAgainstEnumeration) {
    // do the SAME unit of real work as the new-user branch,
    // so there's no synchronous cost difference to leak either
    await this.crypto.hashPassword(password ?? DUMMY_PASSWORD)
    return { status: 'success-pending-verification' }
  }
  return { status: 'email-already-exists' }
}

// new user path
const user = await this.adapter.createUser({ ...})
// fire-and-forget the verification email — deliberately NOT awaited,
// so its cost never shows up in this response's timing at all
void this.sendVerificationEmail(user)

return { status: 'success-pending-verification' }
Enter fullscreen mode Exit fullscreen mode

Both the existing-email and new-email paths return the exact same status, with no extra fields that differ. An attacker probing your /register endpoint with a list of candidate emails gets back one indistinguishable response, every time.

The trade-off you're actually making

This isn't free. A fixed response floor means every legitimate user waits at least that long too, even on your fastest possible code path. At scale, that's real, deliberately-added latency, and in serverless environments specifically, it's billed latency — you're paying for milliseconds you didn't strictly need, on every single request.

The honest way to think about it: pick the floor based on your slowest legitimate branch's realistic p95, not an arbitrary round number. If your real success path takes 60–80ms under load, a 150ms floor is padding a fixed, bounded amount — not multiplying your latency. Measure it, don't guess it.

The takeaway

"Return the same error message" is necessary but nowhere near sufficient for an auth endpoint that needs to resist enumeration. The full checklist looks more like:

  1. Same response shape, in every branch, with no extra/missing fields.
  2. Comparable real work on every branch (a dummy hash comparison when there's no real one to do).
  3. A hard floor on total response time, applied after everything else, covering success and error paths alike.

Skip any one of the three and you've left a measurable signal on the table — and "measurable" is all a patient attacker needs.


This post describes design decisions from beaver-auth, an open-source TypeScript auth package built on Node's built-in crypto. If you're building auth from scratch, beaver-auth's RegistrationEngine and LoginEngine implement all three of the fixes above by default — response floor, enumeration-safe response shapes, and dummy-hash comparisons aren't opt-in extras, they're how the package behaves out of the box.

Top comments (0)