Discovering valid usernames
CWE-204CWE-203OWASP A07:2021Updated September 4, 20265 min read
When an application responds differently to an existing account than to a non-existent one, an attacker can establish which usernames are valid. That difference sits not only in the error message but also in status codes, redirects and response times. With a valid list, every follow-up attack becomes more targeted and cheaper.
A login screen that helpfully reports the password is wrong is also confirming that the username is right. That is convenient for anyone who mistypes and valuable for anyone building a list. This article explains along which routes that difference becomes visible, usually not only through the error message, and how to make the responses identical without leaving your users in the dark.
What is username enumeration?
Username enumeration is establishing which accounts exist in a system by comparing the application’s behaviour. The attacker needs no password and does not have to break in anywhere; they infer the information from the difference between two responses.
That difference need not sit in text. It can be a differing HTTP status code, a different redirect, a slightly longer response time, a difference in the length of the response, or the appearance of a captcha. Any observable distinction between “this account exists” and “this account does not exist” is usable.
It is like ringing the bell at an apartment block where one name produces a buzz and another produces nothing. You get in nowhere, but after fifteen minutes you know exactly who lives there. And that is the information with which the actual attack begins.
Where does the difference sit?
The login page is the best-known place, but rarely the only one.
Vulnerable:
app.post('/login', async (req, res) => {
const user = await users.findByEmail(req.body.email);
if (!user) {
return res.status(404).send('This email address is not known to us');
}
const ok = await bcrypt.compare(req.body.password, user.hash);
if (!ok) {
return res.status(401).send('The password is incorrect');
}
// ...
});
Information leaks here in three ways at once: the text differs, the status code differs (404 against 401), and the response time differs, because bcrypt.compare runs only when the account exists. That last one is the most stubborn, because it survives after the first two are fixed. An attacker simply runs a list through it:
info@company.com → 404, 12 ms → does not exist
j.walker@company.com → 401, 180 ms → exists
The same mistake often recurs in the password reset function (“there is no account with this address”), at registration (“this address is already in use”) and when changing an email address.
Safe:
const DUMMY_HASH = '$2b$12$c8y3Nq0Wl0mVQe1n6JbEZeQmQm2Yy8kQF9nZ0Xf1yqk8dK1vI0Iuq';
app.post('/login', async (req, res) => {
const user = await users.findByEmail(req.body.email);
// Always compare, even without an account: equal computation time
const hash = user?.hash ?? DUMMY_HASH;
const ok = await bcrypt.compare(req.body.password, hash);
if (!user || !ok) {
await randomDelay();
return res.status(401).send('The combination of email address and password is incorrect');
}
// ...
});
The response is now identical in every respect: the same text, the same status code and, because a hash comparison runs even for an unknown account, virtually the same response time. The same rule applies to the password reset function, with a neutral confirmation:
app.post('/forgot-password', async (req, res) => {
const user = await users.findByEmail(req.body.email);
if (user) await sendResetMail(user); // silently skipped otherwise
res.send('If this address is known to us, you will receive an email within a few minutes.');
});
What is the impact of username enumeration?
As a standalone finding this is a matter of low severity: no access is gained and no data from the accounts leaks. Its significance lies in what it enables.
With a verified list of existing accounts, every follow-up attack becomes more efficient. A password attack that first had to work through thousands of non-existent addresses now targets only real accounts, which raises the chance of success and lowers the chance of detection. With password spraying, trying one common password across many accounts, such a list is even the main precondition.
There is also a direct abuse unrelated to logging in. The confirmation that someone has an account is sensitive information in itself. With a medical, legal, financial or politically sensitive service, the existence of an account can reveal more than its contents. And for targeted phishing it is a gift: an email referring to a service the recipient actually uses is considerably more convincing.
How do you detect username enumeration?
A tester sends two series of requests: one with addresses certain to have an account and one with addresses that do not exist. The responses are then compared on everything observable: the text, the status code, the length of the response, the headers, the redirect and the response time.
That happens not only on the login page. The surrounding functions are more often vulnerable: forgot password, registration, changing an email address, invitations, and API endpoints that check the availability of a username for the frontend. That last category is a known one: an endpoint that reports during a registration form whether a name is still free is by definition an enumeration interface. AssistSec also measures response times statistically, because a timing difference that disappears in a single measurement becomes clearly visible over hundreds of requests, and that is exactly how an attacker would approach it.
How do you prevent username enumeration?
- Always give the same message and the same status code at login, regardless of whether the account exists.
- Run a hash comparison even for an unknown account, so the response time does not reveal what happened.
- Confirm neutrally at password reset that an email has been sent if the address is known.
- Do not state at registration that an address is already in use; explain that in the email to that address.
- Make sure captchas, delays and lockouts trigger identically for existing and non-existent accounts.
- Avoid endpoints that check the availability of a username or email address, or restrict them strictly.
- Limit the number of attempts per IP address and per time unit, so large-scale enumeration stands out and stalls.
- Monitor and alert on an unusual number of failed logins with varying usernames.
Sources
Frequently asked questions
Does this matter if usernames are email addresses anyway?
It makes it less serious, but not unimportant. An attacker may know the address but not whether an account exists with you. That confirmation is valuable: it makes targeted phishing more credible and narrows a password attack to accounts that actually exist.
How do I prevent enumeration at registration?
Never state directly that an address is already in use when creating an account. Confirm instead that an email has been sent, and explain in that email whether it concerns a new account or an existing one. The information then reaches only whoever controls the mailbox.
Why does response time matter?
Because an application usually verifies the password for an existing account and does nothing for an unknown one. That computation takes measurable time, often tens of milliseconds. An attacker making thousands of attempts sees that difference clearly, even when the error message is identical.
Is this still a problem with two-factor authentication?
The severity drops, because a valid username and a guessed password are then not enough. But the list stays useful for targeted phishing and for attacks on the second factor itself, such as bombarding someone with push notifications until they approve one.
Related articles
- VulnerabilitiesCWE-287A07:2021Broken authenticationBroken authentication explained: how attackers take over accounts through brute force, leaked passwords and predictable session tokens, and how to stop them.
- VulnerabilitiesCWE-307A07:2021Brute force and credential stuffingBrute force and credential stuffing explained: how attackers guess passwords or replay leaked logins, and how throttling and MFA shut both attacks down.
- VulnerabilitiesCWE-200A01:2021Information disclosureInformation disclosure explained: how stack traces, .git directories, source maps and over-sharing API responses leak data, and how to stop it.
- VulnerabilitiesCWE-308A07:2021Multi-factor authentication is missingWithout a second factor, a leaked password is enough for full access. Learn which MFA methods offer protection and which do not.
- VulnerabilitiesCWE-521A07:2021Weak password requirementsComplexity rules produce weaker passwords than length and a blocklist. Learn which requirements actually work under current guidance.