Email Enumeration Attacks: Stop Account Leaks in Auth Flows
I ran the same two addresses through a small local password-reset fixture. One test account existed and one did not.
leaky-reset existing status=200 bytes=29 hash=f475f0bfde79 median=53.8ms
leaky-reset missing status=404 bytes=29 hash=d58812b2fa2e median=2.1ms
fixed-reset existing status=202 bytes=68 hash=04d299649fc8 median=12.8ms
fixed-reset missing status=202 bytes=68 hash=04d299649fc8 median=12.5ms
The first route leaked account existence three ways: HTTP status, response body, and timing. The second gave both requests one public contract and moved the real account decision behind that boundary.
That is the practical fix for email enumeration. Treat “is this email registered?” as server-side state. Login, password reset, magic-link, resend, signup, and recovery endpoints should not reveal it through text, status codes, redirects, response size, or materially different processing time.
A generic message is only one part of the fix
CWE-204 calls this an observable response discrepancy: different responses reveal internal state to someone outside the intended trust boundary. Changing “Account not found” to “Check your details” fixes the obvious sentence, but the account can still leak through a 404, a different JSON field, a redirect, or the time spent sending email.
OWASP’s authentication guidance applies the same rule to login, password reset, recovery, and account creation. The HTTP and HTML responses both need attention. A generic page over a different status code is still a discrepancy.
Its email-specific guidance is equally direct: attackers should not be able to determine whether an email address is registered. Consistent responses, timing controls, rate limits, and monitoring all matter.
I would audit these client-visible channels together:
- status code and structured error code
- visible message and response length
- redirect target and query parameters
- resend, cooldown, or CAPTCHA behavior
- response-time distributions for existing and missing accounts
Logs are different. Your server may record the real branch, subject to access controls and retention rules. The browser should not receive it.
How should password reset behave?
Return the same accepted response whether the account exists or not. A useful public message is: “If an account can use this flow, we’ll send instructions.” Pick one status, usually 202 or 200, and keep it stable.
Do not synchronously send the reset email before responding. The valid-account branch will then include lookup, token, template, and mail-provider work while the missing-account branch exits quickly. OWASP recommends consistent messages and response times, and suggests asynchronous calls or the same processing path instead of a quick exit.
async function requestPasswordReset(rawEmail) {
const key = accountComparisonKey(rawEmail);
const account = await accounts.findByEmailKey(key);
await resetJobs.enqueue({
accountId: account?.id ?? null,
requestedKey: hmacForAudit(key)
});
return response(202, {
message: "If an account can use this flow, we'll send instructions."
});
}
// The worker sends mail only when accountId exists.
// A missing account becomes a logged, rate-limited no-op.
The queue is not magic timing dust (sadly). Database indexes, cache hits, rate limits, and downstream middleware can still diverge. Test the deployed flow with known test accounts and missing addresses, then compare distributions rather than trusting one request.
Rate-limit the request by network and a privacy-preserving identifier. Keep the public response equivalent when a limit is reached, or the throttle itself becomes another account signal. OWASP also warns that an unbounded reset endpoint can flood a real user’s inbox.
What changes for login and magic links?
A failed login should not distinguish “unknown email,” “wrong password,” “disabled,” or “locked.” Return one failure contract. Internally, keep the actual reason for monitoring and support.
Password login has an extra timing trap. If the account is missing, a quick exit skips password-hash work. OWASP recommends following an equivalent verification path instead. In practice, that usually means comparing against a fixed dummy password hash when no account record exists.
Magic-link login, resend-confirmation, and recovery requests behave more like password reset. Accept the request publicly, queue the private decision, and send mail only to an eligible account. Do not show a countdown only when the account exists.
Must signup hide an existing account?
This is the strongest fair objection: telling a legitimate user that an address already has an account is useful. Yes. It is also an account-discovery feature.
For a high-risk or privacy-sensitive product, I would give new and existing addresses the same browser response: “Check your email to continue.” A new address receives activation; an existing address receives a safe sign-in or recovery notice. No account is created until the new address is confirmed.
A lower-risk product may deliberately show “Email already registered” because the UX benefit is worth the disclosure. That is a threat-model decision, not a technical necessity. If you choose it, rate-limit the endpoint and document that signup reveals membership.
Use the same email comparison policy across signup, login, and reset. Otherwise the message may be generic while normalization differences still reveal or split accounts.
Where should email verification run?
Email verification belongs on a new-address acceptance path, such as signup, an authenticated email change, or a lead form. It evaluates syntax, routing, provider, risk, and mailbox evidence. It does not tell you whether your application already has an account for that address.
If you use the emailverifier.dev API during signup, keep its result separate from your account lookup. A confirmed syntax or routing failure can stop the new-address flow. A disposable or role-address signal is risk evidence for your policy. An unknown result remains inconclusive.
Do not add a verification request to login or password reset and expose its detailed result. That gives the public flow another status, body, and latency branch without proving account existence or inbox ownership. The validation, verification, and confirmation guide explains those boundaries.
How do you test the fix?
Use test accounts you control in staging. For each public identity endpoint, send the same request with an existing test address and a guaranteed-missing address. Compare status, content type, body bytes or hash, redirect location, cookies, visible controls, and several response-time samples.
The local output at the top came from nine requests per branch. Matching two medians in a fixture is not proof of constant-time behavior, but different statuses, hashes, redirects, or obvious timing classes are immediate failures.
Then repeat the test for locked, disabled, unconfirmed, rate-limited, and SSO-only test accounts. The private worker and logs may take different actions. The public contract should stay boring.