Skip to content
DERKONLINE

Prevent User Enumeration in Your Login and Reset Flows

Identical messages, constant-time responses, and honeypots that stop attackers from harvesting valid accounts one request at a time.

Derrick S. K. Siawor8 min read

Before an attacker tries to break into accounts, they want a list of accounts worth breaking into. User enumeration is how they build that list, one request at a time, by asking your application a question it answers too honestly: does this email belong to a real user? A login form that says "no account with that email," a signup form that says "that email is taken," a password reset that confirms the address exists, each one hands the attacker a yes or a no. String enough together and they have a clean roster of your real customers to point a credential-stuffing botnet at.

To prevent user enumeration, make login, registration, and password reset respond identically whether the account exists or not. Return one generic message like "Invalid email or password," handle a duplicate signup by emailing the address instead of confirming it on the form, and equalize response timing so the duration of a request never reveals which emails are real.

The fix is to make every one of those endpoints answer the same way whether the account exists or not. That sounds simple, and the obvious half of it is. The other half, the timing, is where most implementations leak anyway. Here is how to close both.

The three endpoints that leak

User enumeration is not one bug. It is a pattern that shows up anywhere your app behaves differently for a known versus unknown email. Three places leak the most.

Login. The classic. "No account found" for an unknown email and "incorrect password" for a known one tells the attacker exactly which emails are real before they have guessed a single password. Return one message for both: "Invalid email or password." Now a failed login is silent about whether the account exists.

Registration. "This email is already registered" is a confirmation that the email belongs to a user. It is also genuinely useful UX, which is the tension. The resolution is to not confirm it inline. Accept the signup, and if the email already exists, send an email to that address saying "you already have an account, here is how to sign in" instead of telling the person at the keyboard. The real owner finds out through their inbox; the attacker probing the form learns nothing. Fire that notification in the background so the response time stays identical and the form never hangs, exactly as in sending email in the background to keep logins instant.

Password reset. "If that email is registered, we have sent a reset link" is the standard safe response, and it is correct. The trap is what you do behind it. If you only send the email when the account exists, you have moved the leak from the message to the behavior, and a careful attacker can still detect the difference, as you will see in a moment.

Identical messages are necessary but not sufficient

Say you have fixed all three messages. Login returns the same string either way, registration confirms nothing inline, reset always says "if that email is registered." You have closed the obvious leak. You may still be wide open, because the response time is its own message.

This is the timing attack, and it is more practical than it sounds. Consider a login. When the email exists, your code looks up the user and runs the password through a deliberately slow hashing function like scrypt or bcrypt, which takes tens of milliseconds by design. (If you have not set that up yet, hashing passwords with scrypt and timing-safe comparison is the foundation everything here builds on.) When the email does not exist, there is no stored hash to compare against, so the slow hash never runs and the response comes back noticeably faster. The attacker does not read your message at all. They time it. Fast response means no such user; slow response means the account exists. Real accounts revealed, despite a perfectly uniform error string.

The password reset endpoint has the same disease in a different organ. If the code that validates the email format or looks up the user runs before the timing-protection delay kicks in, the time-to-respond still differs between valid and invalid addresses. A real-world advisory against a popular backend found exactly this: the timing protection existed, but URL validation executed before it, so an attacker could still distinguish accounts by response time.

Make the work identical, not just the words

The principle that closes the timing leak: your application should perform the same amount of work whether the user exists or not. The words are downstream of the work. Fix the work and the words follow.

For login, the standard technique is a dummy hash. When the email does not match any user, do not skip the hashing step. Run the supplied password against a pre-computed dummy hash that will never match, so the expensive computation happens on the unknown-user path too. Now both paths spend the same tens of milliseconds in the hashing function, and the response times converge. The attacker times two requests and they look identical, because they did identical work.

const DUMMY_HASH = '...'; // pre-computed once at startup

async function authenticate(email, password) {
  const user = await findUserByEmail(email);
  const hash = user ? user.passwordHash : DUMMY_HASH;
  const ok = await verifyPassword(password, hash);
  if (!user || !ok) return { error: 'Invalid email or password' };
  return { user };
}

The branch that decides success comes after both paths have done the same hashing work. The unknown user still triggers a full password verification against the dummy, so timing tells the attacker nothing.

Dummy hash flow making login do identical work and timing whether the user exists or not

One refinement worth knowing: when you measure or enforce constant time, exclude network read and write time. Network jitter is large and uncontrollable, and trying to pin total wall-clock time including the network is both futile and unnecessary. The work that has to be constant is the server-side computation; the network noise actually helps hide what is left.

This kind of careful, work-equalizing design is the baseline we apply to authentication and account flows in the web applications we build, because the leaky version and the safe version look identical in a demo and only diverge under an attacker's stopwatch. If you are not certain which of your endpoints still leak, a security audit is the fastest way to find them before someone else does.

Honeypots for the bots that just hammer the form

Enumeration at scale is automated, and automation is sloppy in ways humans are not. A honeypot exploits that. Add a hidden form field that a real user, with a real browser rendering the form normally, will never fill in because they never see it. Many bots fill every field they find. When a submission arrives with the honeypot field populated, you know it is a machine, and you can drop the request, count it toward your rate limit, and learn nothing was a real attempt.

The honeypot does not replace the constant-time and uniform-message work; a determined attacker will read your HTML and skip the trap. It thins out the crude high-volume probing that makes up most enumeration traffic, so your other defenses face fewer requests. Combined with rate limiting that treats a tripped honeypot as a failed attempt, it raises the cost of mass enumeration considerably, and the coarse first layer can be pushed all the way out to Nginx rate limit zones so the flood never reaches your app.

A short checklist before you ship

Run every account-touching endpoint through the same test: would an attacker learn whether an email is real from this response, by reading it or by timing it? The pieces that make the answer "no":

  • Login returns one message for unknown-email and wrong-password, and runs a dummy hash on the unknown-user path so the timing matches.
  • Registration never confirms an existing email inline; it tells the real owner by email instead.
  • Password reset always returns the same "if registered, we sent a link" response and does the same amount of work regardless, with any validation that could leak timing placed inside the constant-time envelope, not before it.
  • Constant-time work means equal server-side computation, measured excluding the network.
  • Honeypots catch the high-volume bots, and tripping one counts against the rate limit.

The reason this is worth the care is that enumeration is the reconnaissance step that makes every later attack cheaper. An attacker with a verified list of your real accounts spends their guesses efficiently, then turns that list against your session tokens, which is why issuing JWTs an attacker cannot forge or replay belongs in the same conversation. An attacker who cannot tell your customers from random strings wastes most of their effort on accounts that do not exist. You cannot stop someone from probing your login form, but you can make sure that when they walk away, they know exactly as much as they did before they started.