August 9, 2026
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…

By John Ozoemena
3 min read
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:
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:
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:
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.
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:
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:
- Same response shape, in every branch, with no extra/missing fields.
- Comparable real work on every branch (a dummy hash comparison when there's no real one to do).
- 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.
Originally published at https://dev.to on August 9, 2026.