Account Enumeration
Account Enumeration at a glance
Overview
Account Enumeration happens when an application reveals whether a username or email is registered. The leak might be an explicit message like 'email not found', a different status code, a faster response for non-existent users, or a public user-exists endpoint. Attackers combine enumeration with credential stuffing and social engineering to compromise accounts at scale.
Where it occurs
Common surfaces are login, signup, password reset, account recovery, invite acceptance, and any client-side 'check availability' widgets. It also appears in search or profile endpoints that return 404 for unknown users instead of a neutral 200 with an empty result.
Impact
Attackers build lists of valid accounts and focus brute force, password spraying, or phishing. Enumeration can also expose customer segments or VIP targets. Combined with weak password controls or missing MFA, it increases takeover risk.
Prevention
Use a single, neutral response for login, reset, and recovery flows. Perform the same amount of work whether the user exists or not, for example by comparing the submitted password to a dummy hash when no account is found. Remove or lock down public existence-check endpoints. Apply rate limits, IP and behavior based throttling, captcha challenges where appropriate, and monitor for high-entropy probing.
Examples
Switch tabs to view language/framework variants.
Express, login says 'user not found' vs 'bad password'
Different error messages let attackers probe which emails are registered.
const express = require('express');
const bcrypt = require('bcrypt');
const app = express();
app.use(express.json());
const USERS = new Map();
app.post('/login', async (req,res)=>{
const { email, password } = req.body;
const u = USERS.get(email);
if (!u) return res.status(401).send('user not found'); // BUG: distinct message
const ok = await bcrypt.compare(password, u.hash);
if (!ok) return res.status(401).send('bad password'); // distinct message
res.json({ ok: true });
});- Line 9: Different text for missing user
- Line 11: Different text for wrong password
Distinct messages allow a simple oracle to confirm which emails exist.
const express = require('express');
const bcrypt = require('bcrypt');
const app = express();
app.use(express.json());
const USERS = new Map();
app.post('/login', async (req,res)=>{
const { email, password } = req.body;
const u = USERS.get(email);
const hash = u ? u.hash : '$2b$12$5qfO5oWn8l9tWq9tWq9tWeu5b1m9q1m9q1m9q1m9q1m9q1m9q1m9q'; // dummy bcrypt
const ok = await bcrypt.compare(password, hash);
if (!ok) return res.status(401).send('invalid credentials');
res.json({ ok: true });
});- Line 10: Compare against real or dummy hash to normalize timing
- Line 11: Single error message for all auth failures
Use one generic error and run a constant time comparison with a dummy hash when the account is missing to avoid timing leaks.
Engineer Checklist
-
Return a single generic error for login and recovery
-
Use a dummy hash to normalize timing for missing users
-
Remove public user-exists endpoints or require auth
-
Rate limit and add behavioral throttling on auth flows
-
Avoid echoing unique constraint errors to anonymous clients
-
Monitor for bursts of invalid usernames or emails
End-to-End Example
An Express app uses distinct login messages. An attacker posts a list of emails to the login endpoint and classifies responses to build a list of valid accounts. They later target those accounts with credential stuffing and phishing.
// Node.js/Express - Vulnerable account enumeration
// VULNERABLE: Login with distinct error messages
app.post('/login', async (req, res) => {
const { email, password } = req.body;
const user = await User.findOne({ email });
// VULNERABLE: Different message for non-existent user!
// Attacker can probe which emails are registered
if (!user) {
return res.status(401).json({ error: 'User not found' });
}
const passwordMatch = await bcrypt.compare(password, user.passwordHash);
// VULNERABLE: Different message for wrong password!
// Confirms the email exists
if (!passwordMatch) {
return res.status(401).json({ error: 'Incorrect password' });
}
req.session.userId = user.id;
res.json({ success: true });
});
// VULNERABLE: Signup with immediate feedback
app.post('/signup', async (req, res) => {
const { email, password } = req.body;
const existing = await User.findOne({ email });
// VULNERABLE: Tells attacker email is already registered!
if (existing) {
return res.status(400).json({ error: 'Email already registered' });
}
const user = await User.create({ email, passwordHash: await bcrypt.hash(password, 10) });
res.json({ userId: user.id });
});
// VULNERABLE: Check email availability endpoint
app.get('/api/check-email', async (req, res) => {
const { email } = req.query;
// VULNERABLE: Public enumeration API!
// No authentication required
const exists = await User.exists({ email });
res.json({ available: !exists });
// Attacker can enumerate entire user base
});
// VULNERABLE: Password reset with different messages
app.post('/password-reset', async (req, res) => {
const { email } = req.body;
const user = await User.findOne({ email });
// VULNERABLE: Reveals if email exists!
if (!user) {
return res.status(404).json({
error: 'No account found with that email address'
});
}
// Send reset email
await sendPasswordResetEmail(user);
res.json({ message: 'Password reset email sent' });
});
// VULNERABLE: Timing-based enumeration
app.post('/login-timing', async (req, res) => {
const { email, password } = req.body;
const user = await User.findOne({ email });
if (!user) {
// VULNERABLE: Returns immediately for non-existent user!
// No bcrypt comparison, so response is 100ms faster
return res.status(401).json({ error: 'Invalid credentials' });
}
// Expensive bcrypt takes ~150ms
const passwordMatch = await bcrypt.compare(password, user.passwordHash);
if (!passwordMatch) {
return res.status(401).json({ error: 'Invalid credentials' });
}
req.session.userId = user.id;
res.json({ success: true });
// Same error message, but timing reveals user existence!
});
// VULNERABLE: Profile endpoint with 404
app.get('/users/:email', async (req, res) => {
const { email } = req.params;
const user = await User.findOne({ email }, 'email name bio');
// VULNERABLE: 404 for non-existent users
// Should return 200 with empty data instead
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
res.json(user);
});
// VULNERABLE: Invite endpoint that reveals membership
app.post('/invite', async (req, res) => {
const { email } = req.body;
const existing = await TeamMember.findOne({ email });
// VULNERABLE: Tells attacker if email is in organization!
if (existing) {
return res.status(400).json({
error: 'User is already a member of this organization'
});
}
await sendInvite(email);
res.json({ message: 'Invitation sent' });
});
// VULNERABLE: No rate limiting
// Allows rapid enumeration of thousands of emails
app.post('/login', loginHandler); // No rate limit middleware!// Node.js/Express - Secure against account enumeration
const rateLimit = require('express-rate-limit');
// SECURE: Dummy hash for timing normalization
const DUMMY_HASH = '$2b$10$abcdefghijklmnopqrstuv.XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX';
// SECURE: Rate limiting on auth endpoints
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // 5 attempts
message: 'Too many requests, please try again later',
standardHeaders: true,
legacyHeaders: false
});
// SECURE: Login with neutral response
app.post('/login', authLimiter, async (req, res) => {
const { email, password } = req.body;
const user = await User.findOne({ email });
// SECURE: Always do password comparison to normalize timing
// Use dummy hash if user doesn't exist
const hash = user ? user.passwordHash : DUMMY_HASH;
const passwordMatch = await bcrypt.compare(password, hash);
// SECURE: Same error message for both cases
if (!user || !passwordMatch) {
// Log failed attempt for monitoring
logger.warn('Failed login attempt', { email, ip: req.ip });
return res.status(401).json({
error: 'Invalid email or password'
});
}
req.session.userId = user.id;
res.json({ success: true });
});
// SECURE: Signup without revealing existence
app.post('/signup', authLimiter, async (req, res) => {
const { email, password } = req.body;
const existing = await User.findOne({ email });
if (existing) {
// SECURE: Generic message, doesn't confirm email exists
// Optionally send email to existing user informing them of attempt
await sendSecurityAlert(email, 'signup_attempt');
// Still return success to prevent enumeration
return res.json({
message: 'Please check your email to verify your account'
});
}
const user = await User.create({
email,
passwordHash: await bcrypt.hash(password, 12),
verified: false
});
await sendVerificationEmail(user);
res.json({
message: 'Please check your email to verify your account'
});
});
// SECURE: Remove public check endpoint entirely
// Or require authentication if needed
app.get('/api/check-email', requireAuth, async (req, res) => {
// Only available to authenticated users checking their own email
if (req.query.email !== req.user.email) {
return res.status(403).json({ error: 'Forbidden' });
}
const exists = await User.exists({ email: req.query.email });
res.json({ available: !exists });
});
// SECURE: Password reset with neutral response
app.post('/password-reset', authLimiter, async (req, res) => {
const { email } = req.body;
const user = await User.findOne({ email });
// SECURE: Always return same message
// If user exists, send reset email
// If not, do nothing (but don't reveal this)
if (user) {
await sendPasswordResetEmail(user);
logger.info('Password reset requested', { userId: user.id });
} else {
logger.warn('Password reset for non-existent user', { email, ip: req.ip });
}
// Same response regardless
res.json({
message: 'If an account exists with that email, a password reset link has been sent'
});
});
// SECURE: Profile endpoint returns 200 with null data
app.get('/users/:email', async (req, res) => {
const { email } = req.params;
const user = await User.findOne({ email }, 'email name bio');
// SECURE: Always 200, return null if not found
res.status(200).json({
user: user || null
});
// Doesn't reveal existence via status code
});
// SECURE: Invite with neutral response
app.post('/invite', requireAuth, authLimiter, async (req, res) => {
const { email } = req.body;
const existing = await TeamMember.findOne({ email });
if (existing) {
// Don't reveal membership
// Optionally notify existing member of invite attempt
await notifyMember(existing, 'reinvite_attempted');
} else {
await sendInvite(email, req.user.organizationId);
}
// SECURE: Same response either way
res.json({
message: 'Invitation will be sent if the user is not already a member'
});
});
// SECURE: Monitoring middleware for enumeration attempts
const enumerationDetector = (req, res, next) => {
// Track failed login attempts by IP
const key = `attempts:${req.ip}`;
const attempts = cache.get(key) || 0;
if (attempts > 20) {
logger.error('Possible enumeration attack', {
ip: req.ip,
attempts,
path: req.path
});
// Block or challenge with CAPTCHA
return res.status(429).json({
error: 'Too many requests. Please complete CAPTCHA to continue'
});
}
cache.set(key, attempts + 1, 3600); // 1 hour TTL
next();
};
app.use('/login', enumerationDetector);
app.use('/signup', enumerationDetector);
app.use('/password-reset', enumerationDetector);Discovery
This vulnerability is discovered by analyzing error messages, response timing, and status codes returned by authentication endpoints when testing with valid versus invalid account identifiers, revealing whether an account exists.
-
1. Observe own login behavior
httpAction
Submit a login request with your own valid email address but wrong password
Request
POST https://app.example.com/loginHeaders:Content-Type: application/jsonBody:{ "email": "attacker@example.com", "password": "wrongpassword" }Response
Status: 401Body:{ "error": "bad password", "response_time_ms": 203, "note": "Valid email returns 'bad password' after ~200ms (bcrypt hash comparison)" }Artifacts
http_response_body http_status response_time -
2. Test with non-existent email
httpAction
Submit a login request with a random email address that definitely does not exist
Request
POST https://app.example.com/loginHeaders:Content-Type: application/jsonBody:{ "email": "nonexistent99999@example.com", "password": "anypassword" }Response
Status: 401Body:{ "error": "user not found", "response_time_ms": 48, "note": "Non-existent email returns 'user not found' after ~50ms (no hash comparison)" }Artifacts
http_response_body http_status response_time -
3. Confirm differential behavior
analysisAction
Compare the error messages and response times between valid and invalid email addresses
Request
ANALYSIS N/A - Analysis stepResponse
Status: 200Body:{ "analysis_results": { "valid_emails": { "error_message": "bad password", "avg_response_time_ms": 205, "pattern": "consistent across 100 samples" }, "invalid_emails": { "error_message": "user not found", "avg_response_time_ms": 51, "pattern": "consistent across 100 samples" }, "timing_ratio": "4.0x slower for valid emails", "conclusion": "Account enumeration confirmed via both message content and timing analysis" } }Artifacts
timing_analysis error_message_patterns -
4. Identify target email patterns
analysisAction
Compile a list of likely target emails based on company domain, common naming patterns, and leaked credential databases
Request
ANALYSIS N/A - Analysis stepResponse
Status: 200Body:{ "target_list_generation": { "company_domain": "example.com", "patterns_used": [ "firstname.lastname@example.com", "firstinitial.lastname@example.com", "firstname@example.com" ], "breach_database_matches": 847, "total_candidates": 10000, "list_file": "targets_example_com.txt" } }Artifacts
target_email_list
Exploit steps
An attacker exploits this by automating requests to test large lists of potential email addresses or usernames, classifying responses to build a verified list of valid accounts, which is then used for targeted credential stuffing, password spraying, or phishing campaigns.
-
1. Automate enumeration process
Build enumeration script
analysisAction
Create an automated script to test the email list against the login endpoint, parsing responses to classify valid vs invalid accounts
Request
ANALYSIS N/A - Analysis stepResponse
Status: 200Body:{ "script_output": "Enumeration tool v1.0\nClassifying based on error messages...\n- 'bad password' = VALID account\n- 'user not found' = INVALID account\n- Timing threshold: >150ms = likely VALID\nReady to process 10,000 candidates" }Artifacts
enumeration_script log_line -
2. Execute enumeration campaign
Batch POST login attempts
httpAction
Send automated login requests for each candidate email with a fixed wrong password, rate-limited to avoid detection
Request
POST https://app.example.com/loginHeaders:Content-Type: application/jsonBody:{ "email": "<EMAIL>", "password": "TestPassword123", "note": "Testing 10,000 emails over 2 hours at 1.4 req/sec" }Response
Status: 200Body:{ "enumeration_results": { "total_tested": 10000, "valid_accounts_found": 847, "invalid_accounts": 9153, "hit_rate": "8.47%", "duration_minutes": 120, "detection_avoided": "No rate limiting or blocking observed" } }Artifacts
http_response_body valid_accounts_list -
3. Launch credential stuffing attack
Test common passwords
httpAction
Use the validated email list with common passwords from breach databases to attempt account compromise
Request
POST https://app.example.com/loginHeaders:Content-Type: application/jsonBody:{ "email": "<VALID_EMAIL>", "password": "<COMMON_PASSWORD>", "note": "Testing 847 valid emails x 100 common passwords" }Response
Status: 200Body:{ "stuffing_results": { "accounts_tested": 847, "passwords_per_account": 100, "successful_compromises": 23, "success_rate": "2.7%", "compromised_accounts": [ "admin@example.com:Password123", "john.smith@example.com:Welcome1", "sarah.jones@example.com:Summer2023" ], "session_tokens_acquired": 23 } }Artifacts
session_cookie compromised_accounts_list -
4. Execute targeted phishing
Send phishing emails
analysisAction
Craft convincing phishing emails to the confirmed valid accounts, using platform-specific branding and urgency tactics
Request
ANALYSIS N/A - Analysis stepResponse
Status: 200Body:{ "phishing_campaign": { "targets": 847, "emails_sent": 847, "click_through_rate": "8.2%", "credentials_harvested": 69, "harvest_rate": "8.1%", "success_factor": "Knowing emails are registered made phishing highly credible" } }Artifacts
phishing_template campaign_metrics
Specific Impact
The attacker assembles a verified list of customer emails. This list is used to run password spraying with common passwords, which succeeds for several high value accounts that lacked MFA. The attacker also targets those users with convincing phishing since the addresses are known to be valid on the platform.
Support and security teams see an uptick in login alerts and suspicious sessions. Incident response requires password resets and customer communication.
Fix
Unify responses for all authentication failures. Normalize timing by doing equivalent work for missing accounts using a dummy hash. Remove public existence endpoints and keep uniqueness errors behind authenticated flows. Add rate limits and behavior based throttles.
Detect This Vulnerability in Your Code
Sourcery automatically identifies account enumeration vulnerabilities and many other security issues in your codebase.
Scan Your Code for Free