User Enumeration
Also known as: Account enumeration, Username enumeration, Account discovery oracle
A weakness in which a login, signup or password-reset flow reveals whether an account exists, through different messages, status codes or response times, letting someone build a list of valid usernames.
How it works
User enumeration is an information leak in an authentication flow. If the application answers differently when an account exists than when it does not, anyone who can submit the form can learn which usernames or email addresses are registered. The leak is rarely the harm by itself; it is the first step that makes password guessing, credential stuffing and targeted phishing cheaper and more accurate.
The oracle takes three common shapes. The message differs, such as a login that says no such user for one case and wrong password for another. The status code or redirect differs, for example a 404 versus a 401 or a different landing page. Or the response time differs, because the server only hashes the password when the account was found. Signup and reset flows leak the same way when they say that an email is already registered or that a reset message was sent.
A reviewer finds the oracle by reading each branch of the handler and asking whether the outside observer can tell the branches apart by body, status, headers or latency. Any visible difference is a finding, even a subtle one.
The fix is to make every outcome look the same. Return one generic message for all failures, use the same status code and redirect, do equal work on every path so timing does not give the answer away, and tell reset and signup users to check their email rather than confirming existence. Add rate limiting and bot friction so that large-scale probing is slow and noticed.
Walk through it
- 1Read the login handler
- 2Name the leaking signal
- 3Review the reset flow
- Make every path look the same
- Throttle and verify the fix
A pre-release review flags the sign-in endpoint. Read both failure branches as a reviewer and ask what an outside observer can tell apart.
1// POST /login2router.post("/login", async (req, res) => {3 const user = await db.users.findByEmail(req.body.email);4 if (!user) return res.status(404).json({ error: "No account found for that email" });5 const ok = await verifyHash(req.body.password, user.passwordHash);6 if (!ok) return res.status(401).json({ error: "Incorrect password" });7 return res.json({ session: issueSession(user) });8});Spot it
- Bursts of sign-in or reset requests from one source or a small set of sources, each using a different username or email.
- A high ratio of failed attempts that name unregistered accounts compared with the normal baseline.
- Sequential or dictionary-ordered usernames, or addresses at a single domain, appearing in rapid succession.
- Scripted client signatures: no cookies, a uniform user agent, perfectly regular timing between requests.
- A spike in reset-email volume or signup requests that report an address already registered.
Application auth log
ts=2026-10-11T10:02:11Z event=login_failed reason=unknown_user email_hash=7a1c ip=203.0.113.40 ua=python-requests
ts=2026-10-11T10:02:11Z event=login_failed reason=unknown_user email_hash=b93e ip=203.0.113.40 ua=python-requests
ts=2026-10-11T10:02:12Z event=login_failed reason=bad_password email_hash=02df ip=203.0.113.40 ua=python-requestsWAF
ts=2026-10-11T10:02:30Z rule=auth-endpoint-rate action=challenge host=portal.contoso-rewards.example path=/login ip=203.0.113.40 count=240/60ssplSplunk: one source probing many distinct accounts
index=app sourcetype=auth event IN (login_failed,reset_requested)
| bin _time span=5m
| stats dc(email_hash) as accounts count by _time, ip
| where accounts > 50Tune the threshold to your baseline; shared corporate NAT addresses can look busy.
grepgrep: failed sign-ins by source in the auth log
grep 'event=login_failed' auth.log | grep -o 'ip=[0-9.]*' | sort | uniq -c | sort -rn | headStop it
Return one generic response for every outcome
Use the same message, status code, redirect and headers whether the account exists or not. For sign-in say that the username or password is incorrect. For reset and signup say that an email will be sent if the request can be processed.
Do equal work on every path
Run a password hash comparison even when the account was not found, and do the slow email or queue work in the background, so response time does not separate the cases.
Rate limit and add friction
Limit attempts per source and per account, add a CAPTCHA or proof-of-work after repeated failures, and alert on wide probing. Prefer sign-in by email link or an external identity provider where the design allows.
Uniform login and reset handlers
Vulnerable
const user = await db.users.findByEmail(req.body.email);
if (!user) return res.status(404).json({ error: "No account found" });
if (!(await verifyHash(req.body.password, user.passwordHash))) {
return res.status(401).json({ error: "Incorrect password" });
}Hardened
const GENERIC = { error: "Incorrect email or password" };
const user = await db.users.findByEmail(req.body.email);
// Always hash, even for an unknown account, so timing matches.
const hash = user?.passwordHash ?? DUMMY_HASH;
const ok = await verifyHash(req.body.password, hash);
if (!user || !ok) return res.status(401).json(GENERIC);
return res.json({ session: issueSession(user) });
// Reset: same reply for every address; send mail in the background.
router.post("/forgot-password", async (req, res) => {
queue.add(() => sendResetIfAccountExists(req.body.email));
res.json({ message: "If an account exists, we have sent an email." });
});Frameworks and identity providers often include safe defaults; use them where available.
- Use one message and one status code for all sign-in failures.
- Make reset and signup replies identical for registered and unregistered addresses.
- Hash against a dummy value for unknown accounts so timing does not differ.
- Send emails asynchronously so slow mail delivery does not leak existence.
- Rate limit per source and per target account, with CAPTCHA after repeated failures.
- Alert on many distinct accounts probed from one source.
- Remember that profile pages, API responses and error pages can leak the same signal.
If it already happened
Deploy generic responses for sign-in, reset and signup, and apply emergency rate limits or bot challenges on the affected endpoints.
Identify any other endpoint or error page that distinguishes accounts, and correct timing differences between code paths.
Watch for follow-on password guessing or phishing against the exposed accounts, and prompt high-value users to enable multi-factor authentication.
Add a regression test that compares responses for known and unknown accounts, and a review checklist item for every new authentication flow.
Check yourself
1. A sign-in page says no such user for unknown accounts and wrong password for known ones. What is the weakness?
2. Even with identical messages, why might response time still leak whether an account exists?
3. Which password-reset response best avoids enumeration?