Engineering · Sep 13, 2026 · 6 min read

Account enumeration: confirming user existence via timing oracle

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.

01Why the hash creates the gap

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.

known user POST /api/login {username: "alice", password: "nonce-62615533"} ──► DB lookup hit ──► bcrypt(nonce, stored_hash) ~160 ms ──► 401 Unauthorized unknown user POST /api/login {username: "ghost-62615533@invalid", password: "nonce-62615533"} ──► DB lookup miss ──► early return, no hash ~5 ms ──► 401 Unauthorized
Same HTTP status, same response body, 155 ms apart. The hash is the clock.

02Statistical confirmation, not a single measurement

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.

timing-enum-probe.pypython
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.

03The surface is wider than the login form

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.

EndpointTiming sourceTypical deltaNotes
/api/loginbcrypt / argon2 hash80-200 msClearest signal; 20 samples per candidate usually sufficient
/api/password-resetDB lookup + email queue5-30 msAsync email dispatch reduces but rarely eliminates variance
/api/registerUniqueness constraint check2-15 msOften direct boolean disclosure; timing is the fallback
/api/username-checkAvailability API responsedirectBoolean response body; no oracle required in most cases
Proven - MediumAccount enumeration via timing - /api/login
ghost (nosuchuser-62615533@example.invalid) median: 7 ms (20 samples) alice (alice@target.example) median: 182 ms (20 samples) delta ──► 175 ms, consistent across all 20 pairs; nonce password nonce-62615533 ensures no login attempt completes
Oracle: timing differential, 20 samples per username. No record written or read. Severity medium - enumeration is the confirmed capability; escalates to high when the account list is used to target a credential-stuffing campaign against a service with high-value accounts.

04What enumeration enables and how severity follows

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.

20
samples per username tested
175ms
confirmed timing delta on the live account
0
records written or read during the probe

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.

Evidence over heuristicsReachable beats foundMonitor → Block: rolling out a gate without slowing teamsIaC-grounded threat modelingAir-gapped AppSec with no phone-homeOne platform vs three tools
See it on your own app →