A login endpoint that returns the same error message for a wrong password and a nonexistent account appears to give nothing away - until you measure response time. A bcrypt or argon2 hash computed only for real accounts leaves a 150 ms gap that twenty samples per candidate confirm without touching any live credential.
A login endpoint that returns the same error message for a wrong password and a nonexistent account appears to give nothing away. The timing of that response tells a different story. When an application runs bcrypt or argon2 only for accounts that actually exist, the response for a real username arrives roughly 150 to 200 ms later than the response for a ghost. Twenty samples per candidate and a median comparison are enough to confirm existence without touching any live credential or writing any record.
Bcrypt and argon2 are slow by design - a work factor chosen to make brute-force expensive for anyone who steals the hash file. That same cost creates a timing gap in applications that skip the hash when the username is not found. The correct implementation always computes the hash against a dummy stored value to keep response time constant. Many applications do not, and the gap is measurable with plain HTTP requests and a stopwatch.
A single timing measurement is noise. A median over twenty samples per username reduces jitter enough that a 50 ms delta is reliable and a 150 ms delta is definitive. The probe uses a nonce password that will never match any stored hash - 62615533 serves as a benign marker - so no login is attempted and no session is created regardless of which branch the application takes.
import requests, time, statistics TARGET = "https://target.example/api/login" NONCE = "nonce-62615533" # never matches any stored hash def sample(username, n=20): times = [] for _ in range(n): t0 = time.perf_counter() requests.post( TARGET, json={"username": username, "password": NONCE}, timeout=10) times.append(time.perf_counter() - t0) return statistics.median(times) ghost = sample("nosuchuser-62615533@example.invalid") alice = sample("alice@target.example") delta = (alice - ghost) * 1000 print("ghost %dms alice %dms delta %dms" % (ghost * 1000, alice * 1000, delta)) # ghost 7ms alice 182ms delta 175ms -- user exists
The threshold for a confirmed finding is a delta above network jitter - typically 50 ms with bcrypt at cost 10, and 80 ms or more at cost 12. An application that adds artificial sleep in the user-not-found path collapses the signal but does not eliminate it entirely; a large enough sample set still separates the two distributions when the artificial delay is not drawn from the same distribution as the real hash cost.
Any endpoint that varies its work based on whether an account exists is a candidate. Password reset, registration, and username availability checks all carry the same class of gap, and several are more direct - a registration form that immediately returns 'email already taken' does not need a timing oracle at all. The timing approach matters most where the application correctly unifies the error message but not the response time.
| Endpoint | Timing source | Typical delta | Notes |
|---|---|---|---|
| /api/login | bcrypt / argon2 hash | 80-200 ms | Clearest signal; 20 samples per candidate usually sufficient |
| /api/password-reset | DB lookup + email queue | 5-30 ms | Async email dispatch reduces but rarely eliminates variance |
| /api/register | Uniqueness constraint check | 2-15 ms | Often direct boolean disclosure; timing is the fallback |
| /api/username-check | Availability API response | direct | Boolean response body; no oracle required in most cases |
Confirming that an email address belongs to a registered account converts a public email list into a targeted credential-stuffing list. The timing oracle alone is a medium finding - it reveals no credential and requires many requests to enumerate a useful set. The severity climbs to high when the application stores health records, financial data, or credentials that unlock downstream services. It climbs to critical when combined with a password-reset flow that leaks a token in a redirect or a response body.
The correct fix is a single constant-time check: always run the hash, even for usernames that do not exist, against a dummy stored hash of the same algorithm and work factor. Libraries like passlib and bcryptjs expose a helper for exactly this purpose. Adding artificial sleep is a weaker mitigation - it raises the sample count required to separate the distributions but does not eliminate the oracle. Rate limiting reduces attacker throughput but is not a replacement for constant-time handling.
The pattern is consistent with every timing oracle in the engine: the confirming artifact is a measurement, not a string in the response body. A scalar difference that holds across a sufficient sample, using a nonce value that rules out accidental success, is the proof - and the same principle governs blind SQL injection, blind SSRF, and boolean-differential NoSQL probes. The hash is just the clock the application forgot to hide.