Skip to content
CyberTemp
All posts
How to Test Your Signup Form for Email Enumeration Leaks

How to Test Your Signup Form for Email Enumeration Leaks

CyberTemp7 min read

Try signing up for an account with an email address that's already registered, and a lot of forms will tell you exactly that — "an account with this email already exists," a different error color, a slightly longer response time. That's an email enumeration leak: the form isn't supposed to confirm who's a customer, but it does anyway. It's a small bug with an outsized consequence, and testing for it takes about ten minutes with a handful of disposable addresses.

What email enumeration actually reveals

Enumeration isn't a data breach. Nobody downloads a database. It's a form that answers a yes/no question it should never answer directly: is this specific email address a customer of ours? Attackers don't need your password to get value out of that answer — they need a list of confirmed real accounts to point a phishing campaign, a credential-stuffing run, or a targeted password-reset attack at. A wordlist of ten thousand emails becomes a wordlist of eight hundred confirmed accounts once a leaky signup form has filtered it for them.

The leak is rarely dramatic. It's a status code that differs by one digit, an error message with different wording, or a response that takes noticeably longer because the server did a real database lookup instead of short-circuiting. Any of those is enough for a script to tell existing accounts from new ones at scale.

Where it hides: signup, login, and password reset

Three surfaces leak this most often, and most audits only check one of them:

  • Signup forms that return "email already registered" instead of a generic error, or that succeed silently for new emails but bounce immediately for existing ones.

  • Login forms that say "no account found" for an unknown email but "incorrect password" for a known one — two different messages that only make sense if the server already knows which bucket you're in.

  • Password-reset flows that send an email only for real accounts and show a visibly different confirmation screen (or none at all) for addresses that aren't registered.

Password reset is the sneakiest of the three, because the leak often isn't in the visible copy at all — it's in whether an email actually lands in the inbox a few seconds later.

Why it's worth fixing, not just noting

A generic "if this account exists, we sent a link" message costs nothing to implement and closes the leak completely. The reason it's worth doing before someone else finds it: enumeration is almost always the first step of a bigger attack, not the attack itself. Someone confirms which addresses on a purchased or scraped list are real accounts on your product, then runs those confirmed addresses through a credential-stuffing tool using passwords leaked from an unrelated breach. The enumeration step is what makes that attack efficient enough to be worth running in the first place. Cut it off and the rest of the attack gets a lot more expensive to pull off.

Testing a form by hand in the browser

You don't need to be a developer to run the first pass of this test. Open cybertemp.xyz and generate two throwaway addresses — something like [email protected] and [email protected]. Register one of them on the site you want to test, then leave the other alone. Go back to the signup form, the login form, and the password-reset form, and submit each address in turn. Watch for anything that differs: wording, response time, whether a confirmation email actually arrives in the CyberTemp inbox for one address and not the other. That's the leak, and you found it without writing a line of code.

Automating the check against your own signup flow

If you're testing your own product and want to check this on every deploy instead of by hand, the same idea scripts easily. Generate one address that's already registered in your test environment and one that isn't, fire both at the endpoint you're checking, and compare the responses:

import httpx, time

def probe(url, email): start = time.time() resp = httpx.post(url, json={"email": email}) elapsed = round(time.time() - start, 3) return resp.status_code, resp.text[:150], elapsed

existing = "[email protected]" brand_new = "[email protected]"

for label, addr in [("existing", existing), ("new", brand_new)]: status, body, elapsed = probe("https://yourapp.example.com/password-reset", addr) print(label, status, elapsed, body)

Anything short of identical status codes, identical wording, and comparable timing between the two runs is a finding. For the password-reset case specifically, confirm what actually got delivered by reading the throwaway inbox back through the API instead of trusting the on-screen confirmation alone:

import httpx

API_KEY = "tk_..." HEADERS = {"X-API-Key": API_KEY}

resp = httpx.get( "https://api.cybertemp.xyz/getMail", params={"email": existing}, headers=HEADERS, ) messages = resp.json() print(f"{len(messages)} message(s) landed for the existing-account address")

Run the same read against the address that was never registered. If a reset email shows up for both, the endpoint is leaking through delivery even if the on-screen copy is identical. GET /getMail creates the address on first read if it doesn't already exist, so there's no separate setup call before you can start polling either inbox.

What a fixed response looks like

The fix is almost always the same regardless of which of the three surfaces leaks: return one message, in one shape, in roughly the same amount of time, whether or not the account exists. "If an account with this email exists, we've sent a reset link" instead of two different messages. A signup form that queues the same "check your inbox" screen either way and lets the actual email — sent or not — carry the distinction. Add a small random delay or do the lookup unconditionally so timing doesn't become the new leak once the wording is fixed. None of this requires new infrastructure, just consistency across the three surfaces.

Bottom line

Email enumeration is a small, boring bug that makes every other attack against your users' accounts cheaper to run. It takes two throwaway addresses and a few minutes to check for, and a copy change to fix once you find it. Check all three surfaces — signup, login, and password reset — because a form can fail this test in one place while passing it everywhere else.

For the manual check, cybertemp.xyz works entirely in the browser: generate two addresses, run them through the forms you're testing, and compare what comes back without installing anything. It's the fastest way to get a first answer on whether your product has this problem.

If you want this running on every deploy instead of by hand, the probe script above drops into a CI job as easily as any other integration test. FREE is enough to run the two-address check locally; once it's wired into CI, the ECO or CORE tiers give you enough concurrent inboxes to test signup, login, and reset in parallel without waiting on rate limits, and ELITE adds webhooks if you'd rather not poll for the reset email at all. Sign up if you're the one maintaining that pipeline.


Related reading: how to check if a website actually verifies your email covers the companion test for confirmation flows, how to stop leaking your real email on app signups covers the user-side half of this problem, and testing email deliverability before shipping a signup flow walks through the broader pre-launch checklist this fits into.

Share