A credential-stuffing attacker does not start with passwords. They start with a valid account list. Most login APIs deliver that list for free, before any authentication has failed.
3 independent channels leak account existence to unauthenticated callers. Those channels are: HTTP status code, response body text, and response timing. Each channel is exploitable independently. Each requires its own control. APIs that fix 1 channel and leave 2 open remain vulnerable.
HTTP Status Codes as a Zero-Overhead Enumeration Oracle
CVE-2026-23511 documented this pattern in Zitadel. The login endpoint returned 404 for nonexistent usernames and 401 for valid usernames with a wrong password. No brute force needed. 1 request per candidate was enough to separate existing accounts from nonexistent ones.
Infrastructure cost to the attacker is zero. Enumeration happens in 1 round-trip per candidate username. No additional server interaction is required.
OWASP A07:2021 (Identification and Authentication Failures) covers this class directly. The guidance is clear: all authentication failures must return the same HTTP status code, regardless of whether the account exists. Correct behavior is 401 for every failure case.
The ecosystem has no consensus on this. The Authress knowledge base documents active developer disagreement over whether user-not-found should return 404, 401, or 400. That absence of consensus means many frameworks return 404 by default, delivering free enumeration from the first deployment.
The same principle applies to other protected resources. GitHub returns 404 for private repositories instead of 403, avoiding confirmation of resource existence. Login endpoints should follow the same logic for account existence.
Response Body as a Plaintext Account List
CVE-2024-24766 in CasaOS shows the most common pattern in CVE records. The login endpoint returned "User does not exist" for unknown usernames and "Invalid password" for valid usernames with a wrong password. CVSS 6.2, CWE-204 (Observable Response Discrepancy). The partial fix was bypassed by CVE-2024-28232 using the same root cause.
3 HackerOne reports confirm this pattern is accepted as a valid finding. HackerOne #806151 (Endless Group): the password reset API returned distinct messages per username validity. HackerOne #1166054 (UPchieve): the forgot-password endpoint distinguished registered from unregistered emails in the response body. HackerOne #666722 (Omise): the signup page confirmed existing accounts through distinct error messages.
All 3 cases share the same structure. The server branches the error message based on account existence. No authentication attempt is needed. The attacker only needs to observe the difference between 2 responses.
Developers typically prioritize specific error messages because they improve user experience. That UX decision creates exactly the channel that enables enumeration. CWE-204 classifies this as a design flaw, not a coding bug.
Correct behavior is 1 generic message for all failure cases: "Invalid credentials." That message does not vary based on account existence. The same rule applies equally to login, password reset, and registration.
bcrypt Early-Exit Creates a 207,667x Timing Ratio
CVE-2026-44255 in Wazuh (CVSS 5.3) quantifies the problem precisely. Valid usernames average 124.6ms to reject. Invalid usernames average 0.0006ms. The ratio is 207,667x.
The root cause is bcrypt early-exit. When the username lookup fails, the server returns immediately without running the hash comparison. bcrypt only runs when the account exists. That shortcut turns the server into a high-precision timing oracle.
CVE-2026-23849 in FileBrowser documents the same root cause. Invalid users take roughly 1ms (database lookup only). Valid users take 50ms or more (database lookup plus bcrypt). A short-circuit OR operator in the authentication check causes the early exit. Patched in version 2.55.0. CVE-2026-27480 in Static Web Server shows the same pattern in Basic Auth.
GHSA-g3hg-j4jv-cwfr in Traefik's BasicAuth middleware documents the same pattern in a production reverse proxy. Authentication at the proxy level shares the same early-exit vulnerability. The root cause is not confined to application frameworks.
The attack requires no specialized hardware. PortSwigger's Turbo Intruder sends requests in parallel batches. Calibration uses 20 baseline samples with random usernames. The threshold is: mean plus 5 standard deviations plus 0.005s. 5 median measurements per candidate reduce the false-positive rate to near zero.
The correct fix is to always compute a dummy bcrypt hash when the user does not exist. The dummy hash must use the same cost factor as production hashes. The server stores that constant hash at startup. The comparison step is never skipped, regardless of the account lookup result.
The CVE Record Confirms Enterprise Software Is Affected
CVE-2025-41252 in VMware NSX documented unauthenticated username enumeration in enterprise network security infrastructure. Published April 2026. Unauthenticated actors could confirm account existence before any login attempt.
CVSS scores do not reflect the real impact of enumeration. CVE-2026-44255 scores 5.3. CVE-2024-24766 scores 6.2. The value is downstream: a verified account list amplifies every subsequent credential attack.
Documented bounty for password-reset enumeration enabling account takeover: $3,000 to $7,000 across the 3 HackerOne programs cited. CVE-2026-44255 affected all Wazuh 4.x versions before being patched in 4.14.6. Wazuh is a widely deployed SIEM/XDR platform. Wide adoption combined with a simple root cause explains why this pattern keeps appearing in new CVE records. OWASP documented it years before these CVEs were filed.
Enumeration Is the Prerequisite, Not the Attack
Without a verified account list, an attacker submits the full combo list. Over 99.9% of requests fail on nonexistent accounts. Volume-based rate limiting triggers before valid account hits accumulate.
With a verified list, request volume drops between 90% and 99%. The attacker stays below rate-limiting thresholds. Detection then requires per-account anomaly detection rather than aggregate volume.
The 23andMe breach in 2023 exposed 6.9 million genetic profiles. Credential stuffing ran against confirmed accounts from prior breach lists. Enumeration of confirmed accounts is what makes stuffing efficient.
2 additional chains depend on the verified list. Push-based MFA is only relevant for accounts that exist: enumeration identifies valid targets before any authenticated request. Confirmed email addresses receive spear phishing lures tailored to the service's data type.
3 Channels, 3 Controls
Each channel requires a separate control. A system that unifies error messages but skips dummy hash computation remains fully exploitable via timing. A system that fixes timing but keeps distinct response body messages remains enumerable by body.
Status code channel: return an identical 401 for all authentication failures in a single middleware layer. No account-lookup result should influence the returned status code.
Response body channel: return 1 generic message "Invalid credentials" for all failure paths. No branch based on account existence in the response body builder. The same rule applies to login, password reset, and registration.
Timing channel: always compute bcrypt against a pre-stored dummy hash when the account lookup fails. The dummy hash uses the same cost factor as production hashes. The server never short-circuits before the hash comparison.
For password reset, the correct pattern is a uniform response: "If that email is registered, you will receive a reset link." Identical body and status regardless of registration status.
The MAGO Intel tool (intel.mago.team) tests all 3 channels during API security assessments. The tests cover status code uniformity, response body differentiation, and timing variance across valid and invalid usernames.
Rate limiting is complementary, not a substitute. Timing attacks succeed at 1 request per 10 seconds given sufficient samples.
An API that fixes the status code channel but leaves timing vulnerable has removed the easiest attack and left the most powerful one open.
Top comments (0)