Email enumeration is the process of determining whether a specific email address has an account in a system. It sounds minor — after all, if an attacker knows your email, what can they do with it? In practice, knowing which emails have accounts dramatically improves the efficiency of credential stuffing attacks (skip addresses with no account), enables targeted phishing (these users definitely use the service), and reveals private information (an HR platform for example should not leak which employees use it). Preventing enumeration requires careful attention to response consistency at both the message and timing level.
Enumeration vectors
Any endpoint that behaves differently for existing vs non-existing email addresses is an enumeration vector. The obvious ones:
- Login: "Invalid email" vs "Invalid password" — reveals whether the email exists.
- Forgot password: "Email sent" vs "No account found" — directly leaks existence.
- Registration: "Email already in use" — confirms the email has an account.
- Email change: allowing an attacker to attempt to change their email to another address and observe whether it is "already taken."
The less obvious one is timing. Even if you return identical response messages and HTTP status codes, if your handler takes 200ms when the user exists (because it runs a password hash check) and 2ms when the user does not exist (because it short-circuits), the response time leaks the same information as an explicit "email not found" message.
Consistent response messages
For login, return the same message regardless of whether the email exists or the password is wrong. "Invalid credentials" covers both cases. Never say "Invalid email" or "No account found for this email."
For forgot password, always respond with "If that email address has an account, a reset link has been sent." Then silently do nothing if the email does not exist, or send the email if it does. The user cannot distinguish between the two cases from the response.
// Forgot password — consistent response regardless of email existence
app.post('/auth/forgot-password', async (req, res) => {
const { email } = req.body;
// Always return 200 with the same message
// Never reveal whether the email exists
res.json({
message: 'If an account exists for that email address, a reset link has been sent.'
});
// Process asynchronously — don't let processing time leak information
setImmediate(async () => {
try {
const user = await db.users.findByEmail(email.toLowerCase());
if (!user) return; // silently do nothing
const token = await createPasswordResetToken(user.id);
await sendPasswordResetEmail(user.email, token);
} catch (err) {
logger.error('Password reset processing error', { err });
}
});
});
Timing attacks and constant-time operations
Return the response before doing the asynchronous work (as above), or perform the same work regardless of whether the user exists so the response time is constant. For the login endpoint specifically, you must always run the password hash comparison even when the user does not exist, otherwise the absent database lookup time is detectable.
// Login endpoint — constant-time regardless of user existence
const DUMMY_HASH = await argon2.hash('dummy_password_for_timing_safety');
app.post('/auth/login', async (req, res) => {
const { email, password } = req.body;
const user = await db.users.findByEmail(email.toLowerCase());
let passwordValid: boolean;
if (user) {
// Real password check
passwordValid = await argon2.verify(user.password_hash, password);
} else {
// Dummy check — same argon2 work, prevents timing difference
// Result is always false, but the time taken is consistent
await argon2.verify(DUMMY_HASH, password);
passwordValid = false;
}
if (!user || !passwordValid) {
return res.status(401).json({ error: 'invalid_credentials' });
}
// ... issue tokens
});
Registration enumeration
The registration case is harder because the user genuinely needs to know if they can register with an email. The UX-security tradeoff is unavoidable here. Options:
- Always send an email: when someone tries to register with an existing email, send an email to that address saying "Someone tried to register with your email address. If that was you, here is your login link." The registration UI shows the same message either way. The legitimate account owner is notified. The attacker does not get confirmation from the UI.
- Email verification required: complete registration requires clicking a link in an email sent to the submitted address. This means the attacker never sees confirmation in the browser — they would need access to the email inbox to complete enumeration, at which point they already have more than enough access.
- Accept the tradeoff explicitly: for low-sensitivity applications where user privacy is less critical than UX friction, showing "Email already in use" is acceptable. Document this as a conscious decision.
// Registration — email collision handled with notification, not error
app.post('/auth/register', async (req, res) => {
const { email, password, name } = req.body;
// Always return the same pending message
res.json({
message: 'Check your email to complete registration.'
});
setImmediate(async () => {
const existingUser = await db.users.findByEmail(email.toLowerCase());
if (existingUser) {
// Send "account already exists" notification instead of creating a new account
await sendAccountExistsEmail(email, {
login_url: 'https://app.example.com/login',
reset_url: 'https://app.example.com/forgot-password'
});
return;
}
const token = await createEmailVerificationToken(email, password, name);
await sendVerificationEmail(email, token);
});
});
The security vs UX tradeoff
Enumeration prevention has a measurable UX cost. When a user forgets they already have an account and tries to register again, they get a "check your email" message with no indication of what went wrong. They check their email, see "you already have an account," and must navigate to the login flow. This adds friction for a legitimate user who simply forgot.
The right decision depends on your threat model. For a healthcare application, HR platform, or financial service where knowing who is a customer is sensitive, full enumeration prevention is worth the UX cost. For a public B2C consumer app where your user base is largely public knowledge, the tradeoff may lean toward better UX. What you should never do is implement inconsistent enumeration prevention — some endpoints leaking while others do not — because this gives attackers a partial but still useful signal.