Skip to content
Velaris

Engineering

Email enumeration: how your login form leaks your user list

Identical error messages aren't enough — response timing leaks the same secret. How to make a signup endpoint that reveals nothing about who has an account.

Vithu ·

An email enumeration attack is when someone learns which addresses have accounts on your service by watching how your endpoints respond. It’s rarely the whole breach — it’s the reconnaissance step that makes the next one cheap. And most auth forms leak, including ours until we fixed it.

Why the leak matters

Knowing that person@company.com has an account is worth real money to an attacker. It turns a generic credential-stuffing run into a targeted one, it confirms which addresses to phish, and for some services membership is itself sensitive — a dating site, a medical service, a job board.

The attack is cheap. Feed a list of addresses to a signup or password-reset endpoint, watch the responses, keep the ones that behave differently.

The obvious leak: different messages

The version everyone knows:

  • Sign-up says “That email is already registered.”
  • Password reset says “No account with that address.”

Both are helpful. Both are an oracle: submit an address, read the answer.

The standard fix is a single neutral response regardless of outcome:

If that address can receive a code, one is on its way.

That sentence is deliberately vague about whether anything was sent. Say it identically on every path.

The leak that survives the obvious fix

Here’s the part that gets missed. You can return byte-identical responses and still leak the answer — through time.

Sending an email means a network round-trip to your provider. Not sending one means returning immediately. That’s a difference of hundreds of milliseconds, reliably measurable over a handful of samples, and it perfectly reconstructs the boolean you just went to the trouble of hiding.

The fix is a response-time floor. Hold every response to the same minimum, whichever branch ran:

const MIN_RESPONSE_MS = 450;
async function floor<T>(started: number, value: T): Promise<T> {
  const remaining = MIN_RESPONSE_MS - (Date.now() - started);
  if (remaining > 0) await new Promise((r) => setTimeout(r, remaining));
  return value;
}

Pick a floor comfortably above your slow path. This costs your real users half a second on a flow they perform rarely, which is a good trade for removing a side channel entirely.

What you should still say out loud

Neutrality has limits, and over-applying it makes a hostile product.

Rate limiting is safe to report honestly. “Please wait 60 seconds before requesting another code” describes the caller’s own behaviour. It says nothing about whether the account exists, so hiding it only confuses legitimate users.

Validation errors are safe. “That’s not a valid email address” is about the input string.

And there’s one case where silence is worse than speaking. If someone tries to sign up with an address that already has an account, sending nothing leaves a real user stuck with no idea why. Instead, email that address — not the browser — with a note: someone tried to create an account here, one already exists, sign in or reset your password instead.

The person at the keyboard learns nothing new. The person who owns the address gets told something useful, and gets an early warning if it wasn’t them.

Verification is a different problem

We took the opposite approach on the endpoint that checks a code, and the asymmetry is deliberate.

To reach it you must already hold both an address and a 6-digit code. Telling you “that code expired” versus “that code is wrong” reveals nothing you couldn’t learn by guessing again — while a blanket “invalid” leaves genuine users stuck retyping a code that can never work.

The one thing worth withholding there is how many attempts remain, because that lets a script pace itself to stay just under the lockout.

How to test it

Assertions that catch this, in order of usefulness:

  1. Send to a real address and an unregistered one. Compare response bodies byte for byte.
  2. Compare response times — both should clear your floor.
  3. Check the database: the silent branch should have created no rows. A leak that shows up only in a table is still a leak.
  4. Check the status codes, and the headers.

The third is the one that catches sloppy implementations, where the endpoint returns a neutral message but leaves evidence behind.

Velaris mints and verifies its own sign-in codes rather than delegating them — read how those codes are stored.