User enumeration is a vulnerability where an application reveals whether a given username or email address has an account. It is often dismissed as low severity, but it is the first step in most credential stuffing, password spraying, and targeted phishing campaigns. An attacker with a confirmed list of 50,000 valid emails wastes far fewer requests than one guessing blindly, and your rate limits go further against the attacker who has to guess.
Where Enumeration Hides
Enumeration is rarely one obvious bug. It leaks through several channels:
- Error messages: “No account with that email” versus “Incorrect password”.
- Signup: “That email is already registered”.
- Password reset: “Reset link sent” for real accounts, “User not found” for others.
- Response timing: a valid user triggers a slow password hash comparison, while an unknown user returns immediately.
- Status codes and body sizes: a 200 versus 404, or a response that differs by a few bytes.
Exploitation
The attacker scripts requests from a wordlist of emails (often from a previous breach) and sorts the responses. Even when the messages match, timing gives it away. Password hashes such as scrypt or Argon2 are deliberately slow, often 50 to 300 ms. If the code only hashes when the user exists, the difference is trivial to measure after a handful of samples.
Here is a vulnerable Flask login that leaks through both message and timing:
# vulnerable.py
import hashlib, hmac
from flask import Flask, request, jsonify
app = Flask(__name__)
USERS = {} # email -> (salt, hash)
def hash_pw(password, salt):
return hashlib.scrypt(password.encode(), salt=salt, n=2**14, r=8, p=1)
@app.post("/login")
def login():
email = request.json.get("email", "").lower()
password = request.json.get("password", "")
record = USERS.get(email)
if record is None:
return jsonify(error="No account found for this email"), 404 # message leak
salt, stored = record
if not hmac.compare_digest(hash_pw(password, salt), stored):
return jsonify(error="Incorrect password"), 401 # distinguishable
return jsonify(ok=True)
Two problems exist. The 404 versus 401 tells the attacker which emails are real, and unknown emails skip the expensive scrypt call, so even a normalised message would still leak via timing.
The Fix: Uniform Responses and Equal Work
Return one generic message and status for every failure, and always perform a hash comparison, using a dummy hash when the user does not exist:
# fixed.py
import hashlib, hmac, os
from flask import Flask, request, jsonify
app = Flask(__name__)
USERS = {}
DUMMY_SALT = os.urandom(16)
DUMMY_HASH = hashlib.scrypt(b"dummy", salt=DUMMY_SALT, n=2**14, r=8, p=1)
def hash_pw(password, salt):
return hashlib.scrypt(password.encode(), salt=salt, n=2**14, r=8, p=1)
@app.post("/login")
def login():
email = str(request.json.get("email", "")).lower()
password = str(request.json.get("password", ""))
salt, stored = USERS.get(email, (DUMMY_SALT, DUMMY_HASH))
ok = hmac.compare_digest(hash_pw(password, salt), stored)
if not ok or email not in USERS:
return jsonify(error="Invalid email or password"), 401
return jsonify(ok=True)
The work is now the same for known and unknown emails, and the response is identical.
Password Reset and Signup
These flows are harder because the honest answer is useful to legitimate users. The standard approach is to respond identically and move the real distinction out of band, into email. Here is an Express reset endpoint:
// reset.js (npm i express)
const express = require("express");
const crypto = require("crypto");
const app = express();
app.use(express.json());
const users = new Map(); // email -> { id }
// Vulnerable version, for contrast:
// if (!users.has(email)) return res.status(404).json({ error: "User not found" });
app.post("/reset", (req, res) => {
const email = String(req.body.email || "").toLowerCase();
// Always the same response, regardless of account existence
res.status(202).json({
message: "If that address is registered, a reset link has been sent.",
});
// Do the real work after responding so timing does not leak
setImmediate(() => {
if (!users.has(email)) return;
const token = crypto.randomBytes(32).toString("hex");
// store sha256(token) with expiry, then email the link
console.log("queue reset email for", email, "token hash",
crypto.createHash("sha256").update(token).digest("hex"));
});
});
app.listen(3000);
Sending the email asynchronously matters. If the app calls an email provider inline only for real accounts, the latency difference reintroduces the leak.
For signup, apply the same pattern: always show “Check your inbox to continue”. If the address already exists, email the owner a notice (“Someone tried to register with your address”) instead of showing an error in the browser.
Prevention Checklist
- Use one generic message and one status code for all authentication failures.
- Hash a dummy value when the account does not exist, so response times match.
- Make reset and signup responses identical; carry the real outcome by email.
- Send email through a queue so account existence does not change latency.
- Rate limit per IP and per target account, and add CAPTCHA or proof-of-work after repeated failures.
- Check secondary channels: API endpoints, OAuth callbacks, “check username availability” calls, and verbose GraphQL errors.
- Test by diffing status, body, headers, and median timing across 100 or more samples of known and unknown users.
If a product needs live username availability checks, treat that as an accepted trade-off. Throttle it aggressively, and avoid using email addresses as public identifiers.
Conclusion
Enumeration defences are cheap compared with the downstream cost of credential stuffing. Make every failure look the same, in content and in time, and move any honest explanation to the inbox of the real account owner.