Username Enumeration
Updated August 24, 2026
4 min read
Username enumeration is when an app reveals whether an account exists — via a different error message, a different status code, or a measurably slower response. It isn't account takeover on its own, but it turns a two-unknown problem (who and what password) into one unknown, which is what credential stuffing needs. The fix is a few hours: identical responses on login, registration, and reset, constant-cost password verification, and rate limiting.
What is username enumeration?
Username enumeration (user or account enumeration) is an information-disclosure flaw: an authentication endpoint behaves differently for a valid identity than an invalid one. Anything that varies with account existence is an oracle: submit a candidate list, read the difference.
Content. "No account with that email" versus "Incorrect password."
Status or shape. Identical body text, but the valid user gets 200 and the invalid one 302 — or they differ by a Set-Cookie or a few hundred bytes.
Timing. The subtle one: the app looks up the row and only if it exists runs bcrypt or Argon2 — deliberately expensive, tens to hundreds of milliseconds — so valid usernames answer slower even when the body is byte-identical.
Registration and reset forms leak as much as login and are routinely forgotten — "that email is already registered" is the same oracle in different clothes. It maps to A07:2025 Authentication Failures in the OWASP Top 10 (2021 name: Identification and Authentication Failures). For the platform-specific case, see WordPress user enumeration.
Why it's a risk / how it's exploited
Rated medium, no higher: a typical instance scores CVSS 5.3 (AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N) — remotely reachable and trivial, but confidentiality-only. It is never a compromise by itself; it pays off alongside a second weakness — no rate limiting, no MFA, or reused passwords.
The realistic chain: an attacker replays 50,000 breach-corpus addresses against your login endpoint and keeps the 1,200 your app confirms are customers. Spraying is now cheap — one common password against 1,200 known-valid accounts stays under most lockout thresholds. The list also sharpens phishing, and for a medical or dating site the membership fact alone is already a privacy incident.
How to detect it
Compare a known-good account against a random one, watching status, size, and server think-time:
for u in real.user@example.com nobody-4f2a@example.com; do
curl -s -o /dev/null -b jar.txt \
-w "$u code=%{http_code} size=%{size_download} connect=%{time_connect} ttfb=%{time_starttransfer}\n" \
-X POST https://example.com/login \
-d "email=$u&password=definitely-wrong"
done
Use time_starttransfer minus time_connect, not total time — it strips TLS handshake noise. A leaking endpoint:
real.user@example.com code=200 size=5142 connect=0.052 ttfb=0.331
nobody-4f2a@example.com code=200 size=4998 connect=0.049 ttfb=0.091
A safe endpoint returns the same status, near-identical size, and times within noise of each other. Two caveats: most login forms need a session cookie and CSRF token — fetch the form first with curl -c jar.txt and post the token, or you only measure a uniform 403; and run each request ten times, since one sample is jitter. Then repeat on /register and /forgot-password. Automating that across every endpoint is what web application security testing is for.
How to fix it
One message, one status code, for every failure — and the same CPU cost either way:
# Django + argon2-cffi. PasswordHasher.verify() returns True or raises.
from argon2 import PasswordHasher
from argon2.exceptions import InvalidHashError, VerificationError
ph = PasswordHasher()
DUMMY_HASH = ph.hash("never-matches") # computed once, at import
def authenticate(email, password):
user = User.objects.filter(email=email).first()
stored = user.password_hash if user else DUMMY_HASH # same cost either way
try:
ph.verify(stored, password)
except (VerificationError, InvalidHashError):
return None
return user # None when the account never existed
The caller renders one string — "Invalid email or password." — for both outcomes. The dummy comparison is what kills the timing oracle; unify the wording without it and the leak survives. Generate DUMMY_HASH with the same Argon2 parameters as your real hashes, or the costs still differ. Django's ModelBackend does this out of the box and still needed CVE-2024-39329 to close a residual gap, so measure rather than assume. Same rule for reset: always answer "If an account exists for that address, we've sent a link." While you're there, check the autocomplete tokens on those fields.
Then make bulk probing expensive — nginx's limit_req is compiled in by default, no extra module:
# http {} context only — limit_req_zone is invalid inside server or location
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;
server {
location = /login {
limit_req zone=login burst=5 nodelay;
limit_req_status 429;
proxy_pass http://app;
}
}
Honest caveat: some flows genuinely cannot be silent — a signup form must reject a duplicate email, SSO discovery must route by domain. Google and Microsoft confirm account existence at the email step by design, so "everyone hides it" is a myth. Mitigate instead: accept the signup, then confirm by email ("you already have an account, here's a reset link"), backed by rate limiting. Per-IP limits also do nothing against a distributed attacker; pair them with per-account throttling and MFA.
FAQ
Is username enumeration worth fixing if we already have MFA? Yes, but at lower priority. MFA blocks the takeover step, so the credential-stuffing value drops sharply. What remains is privacy and better-targeted phishing.
Doesn't my login page have to say "no such user" for usability? No. "Invalid email or password" is understood universally. If users get stuck, invest in the password-reset flow — itself generic — rather than a message that doubles as an attacker's oracle.
Not sure which of your auth endpoints leak? Run a free website vulnerability scan — Exploita probes login, registration, and reset flows for content, status, and timing oracles, and reports only what it can prove.
