Account enumeration, explained
Three channels tell an attacker whether an email has an account: the body, the status code, and the clock. How to close all three without breaking signup.
- security
- vibe coding
- engineering
Account enumeration is the ability to ask an application “does this person have an account here” and get a reliable answer. It is not a breach. It is the step before one, and it is the step that decides whether the attacker’s list has ten million addresses or four hundred.
The identifier is CWE-204, observable response discrepancy. OWASP tests for it as WSTG-IDNT-04.
Why a yes/no answer is worth anything
Two reasons, and the second one is the one people underestimate.
It makes credential stuffing economical. An attacker holding a dump of addresses and reused passwords from some unrelated breach does not want to spray your login endpoint with ten million attempts: that is loud, slow, and mostly wasted. Enumeration turns the dump into a shortlist of confirmed accounts, and the shortlist is what gets attacked. Every rate limit you have is sized for the long list.
It is sometimes the whole harm. For a site where having an account is
sensitive, membership is the secret. A dating app, a health service, a support
group, an addiction recovery tool, a whistleblowing platform, a competitor’s
customer list. “Does ceo@rival.example have an account” can be the entire
question an attacker came to answer, and a perfectly secured login form answers
it for free.
That second case is why the fix cannot be “add rate limiting.” Rate limiting raises the cost of the ten million. It does nothing about the one.
The three channels
An endpoint leaks existence through the body, the status code, or the clock. Most vulnerable applications leak through all three and are fixed for only the first.
The body. The obvious one:
{"error": "No account found for that email"}
{"error": "Incorrect password"}
Two distinguishable answers, so the login form is a lookup service.
The status code. The subtler one, and the reason a body fix alone often
fails. A signup that returns 409 Conflict for a taken address and 201 for a
new one is fully enumerable with an empty response body. Same for a password
reset that returns 404 for an unknown address and 200 for a known one.
The clock. The one almost nobody closes. Consider the natural implementation:
// Vulnerable through timing, even with a perfectly generic error message.
const user = await db.user.findUnique({ where: { email } });
if (!user) return res.status(401).json({ error: "Invalid email or password" });
const ok = await bcrypt.compare(password, user.passwordHash);
if (!ok) return res.status(401).json({ error: "Invalid email or password" });
The message is identical in both branches. The response time is not. A missing account returns after one indexed lookup, a few milliseconds. An existing account returns after a bcrypt comparison, which is deliberately expensive: tens to hundreds of milliseconds at any sane cost factor. That gap is not a statistical artifact you need a thousand samples to detect. It is visible in a single request in a browser network tab.
Worth noticing what has happened there. The cost that makes a password hash worth having is the same cost that turns this endpoint into an oracle, which is why the two problems have to be fixed together and why the fix below is shaped the way it is. The other half of that tradeoff, including which cost factor you should actually be running, is in password hashing failures.
The same trap sits in any password reset that sends the email before responding. An address with an account waits for an SMTP or API round trip. An address without one returns immediately.
Closing all three
One answer, one code, one duration. For login, do the expensive work unconditionally:
import bcrypt from "bcryptjs";
// A hash of a random string nobody knows, generated once at startup.
// Comparing against it costs the same as comparing against a real hash.
const DUMMY_HASH = bcrypt.hashSync(crypto.randomUUID(), 12);
export async function login(email, password) {
const user = await db.user.findUnique({ where: { email } });
// Always compare. When there is no user, compare against the decoy.
const ok = await bcrypt.compare(password, user?.passwordHash ?? DUMMY_HASH);
if (!user || !ok) {
return { status: 401, body: { error: "Invalid email or password" } };
}
return { status: 200, body: { token: await issueSession(user.id) } };
}
Two details carry the weight. The comparison runs even when there is no user, so
both paths pay the same cost. And the final check is !user || !ok rather than
an early return, so there is exactly one failure exit and it cannot drift apart
from the other one during a later refactor.
Password reset: answer 200 for everybody. This is the endpoint where the
correct design is most counterintuitive, because the honest-looking response is
the vulnerable one. It is also the endpoint with a second, unrelated failure
class sitting on top of this one: if the link inside that email is built from
the request’s own Host header, an attacker can send a real token to a real
victim with the attacker’s domain in the URL. That is
password reset poisoning, and the
two fixes are independent, so shipping either alone leaves the flow broken.
There is a third way to lose this endpoint, and it is the one that turns an
enumeration bug into an account takeover: put anything in the response body that
differs between a known and an unknown address. CVE-2026-39912 is that failure in
its purest form, because the registered branch returned the login link itself
while the unknown branch returned a bare true, so the same statement leaked the
credential and defeated the uniform-response rule at once. See
magic link security for the code.
Our own control plane implements POST /auth/forgot-password this way, and the
reasoning is worth spelling out because each rule closes a specific channel:
- It answers 200 for every address, whether or not an account exists. The body says a link was sent if the address is registered. That is the only sentence any caller ever gets, so the body and the status code both carry zero bits.
- It acts only for an account that actually exists and has a password (the passwordless demo identity is excluded), so no email is ever sent to a stranger.
- It dispatches the send off-thread and returns immediately. Awaiting a 15-second network call to a mail provider would have re-leaked the answer through the clock after the body and status had been made identical, which is exactly the mistake described above.
- The one non-200 it can return is a service-wide state, not a per-address one: with no mail sender configured, every caller gets the same 503. A response that is identical for all inputs leaks nothing about any input.
- It carries a per-account cooldown enforced inside the insert, and a suppressed request still answers 200 and leaves the existing link valid. This matters because our rate limiting tiers key on the client, so they cannot see one victim’s inbox being flooded from a thousand rotating addresses. Per-IP limits and per-account limits are different controls solving different problems, and an application usually needs both.
Signup: the hard one. You genuinely cannot both tell the user “that address is already registered” and avoid enumeration. Those are the same statement. The resolution that the industry has settled on is to move the answer into the inbox, which only the owner of the address can read:
- Accept the registration. Respond with the same “check your email to continue” for every address, always.
- If the address is new, send the verification link.
- If the address already has an account, send a different email to the same address: someone tried to sign up with your address, here is a sign-in link and a password reset link.
The person who owns the address gets a more useful experience than the error message would have provided. The attacker gets one response, one status code, and one duration.
Two notes on the edges. First, if a “check your email” flow is genuinely too
much friction for your product, say so out loud and accept the enumeration as a
decision with a reason, rather than shipping it as an accident. For a public
developer tool the tradeoff is defensible; for a health app it is not. Second,
whatever you decide, keep the “email already in use” check off any
unauthenticated endpoint that exists purely for form validation. A live
/api/check-email is enumeration with an API.
The places it hides after you fix the login form
- The reset form’s own validation, if it validates the address client-side against an endpoint before submitting.
- OAuth and SSO, where “link your existing account” and “create a new account” are visibly different screens.
- Rate limits themselves. If a known account locks out after five attempts and an unknown one never locks out, the lockout is the oracle. Apply the counter to the submitted address regardless of whether it resolves.
- Multi-factor prompts. An account with MFA showing a second step, and a nonexistent account failing immediately, distinguishes both existence and configuration.
- Verbose error messages in any of the above, where a database constraint error names the unique index.
- Timing on the second factor, same reasoning as the first.
What a scanner sees
Honestly, not much, and it is worth being clear about that rather than implying otherwise. This is a behavioral property of two responses compared to each other, not a pattern in a line of code, so static analysis is the wrong instrument: a SAST engine can flag a distinguishable error string near an auth handler, and it cannot tell you that your reset endpoint returns 40ms faster for strangers.
It is the same reason broken access control is the hardest class to scan for. The evidence is in the difference between two runs, which means it belongs to your own test suite. That is the good news: a test asserting that two responses are byte-identical is easy to write and never goes stale.
test("forgot-password does not reveal account existence", async () => {
const known = await post("/auth/forgot-password", { email: "real@example.com" });
const unknown = await post("/auth/forgot-password", { email: "nope@example.com" });
expect(unknown.status).toBe(known.status);
expect(unknown.body).toEqual(known.body);
});
That test costs three minutes and it is the only thing that will still be enforcing this rule after the next refactor of your auth routes.
The principle
Every authentication endpoint answers exactly one question: are these credentials valid. Anything else it tells the caller (that the account exists, that it is locked, that it uses SSO, that it once did) is a second answer to a question nobody was authorized to ask. Make the two paths indistinguishable in content, in status, and in time, and move the real information to the inbox, where identity has already been proven.