Authentication Vulnerabilities: The Complete Practical Guide

Username Enumeration: Finding Valid Accounts

How to confirm which usernames exist through response, status, length, timing, and lockout differences, plus enumeration via reset and signup forms.

Published

Username enumeration is the quiet first move of almost every authentication attack. If the application behaves even slightly differently for a real account than a fake one, you can map out exactly who exists before you try a single password. That turns a blind guessing game into a targeted one. This page covers every tell and how to automate the diff. Practice on a free live target.

Why enumeration comes first

An unauthenticated attacker faces two unknowns: which usernames are valid, and what their passwords are. Enumeration removes the first. Once you have a confirmed list of accounts, spraying and brute force only waste attempts on real targets, you stay under per-account lockouts, and you can aim straight at the admin instead of the whole user base. It is cheap, low-noise reconnaissance that makes everything after it work better.

Step 1: get a baseline

Send two failed logins and capture both full responses:

  • A username that is almost certainly fake, like zzvnotreal123, with any password.
  • A plausible real username, like admin or a name you harvested, with a deliberately wrong password.

You now have a "definitely invalid" response and a "maybe valid, wrong password" response to compare.

Step 2: diff everything

Any stable difference between those two is an oracle. Check each:

Response body:   "Invalid username"  vs  "Invalid password"      (classic tell)
Trailing detail: "No account found"  vs  "Incorrect credentials"
Status code:     200 vs 302           (a redirect in one case but not the other)
Content-Length:  differs by a few bytes between the two cases
Timing:          valid user is often SLOWER (the password actually gets hashed)
Lockout:         only real accounts get "account locked" after N tries

The most common is the error text: an app that says "invalid username" versus "invalid password" is telling you outright which part was wrong. But even when the message is identical, the status code, a redirect, or a few bytes of length difference often still leak the answer.

The timing oracle

A subtle but powerful tell is time. When you submit a real username with a wrong password, the server usually hashes your password to compare it, which takes measurable time. When the username does not exist, a naive server skips the hash and answers faster. Measure enough requests and the valid accounts stand out as consistently slower.

This is an "often," not an "always." A well-built app defends against it by hashing a dummy password even for missing users, so the timing is equal either way. So treat timing as a strong signal to test, not a guarantee, and always average several measurements against a known-invalid baseline because network jitter is noisy.

Enumeration through other forms

The login form is not the only leak. Two others are frequently more talkative:

Reset form:   "If that account exists we sent an email"   (safe)
              vs  "No account with that email"             (leaks)
Signup form:  "Email already in use"                       (confirms the account exists)

A registration form that rejects an already-registered email is a direct existence oracle, and a reset form that distinguishes "sent" from "no such user" is the same. Account lockout is a third: if only real accounts return "account locked" after several attempts, the lockout message itself confirms existence.

Step 3: script the diff

Enumeration is mechanical, so automate it. Send the same wrong-password login for each username in a wordlist and flag every response that matches your "valid account" fingerprint (the error text, status, length, or timing band you identified). The shape in Python:

import requests
 
valid = []
for user in open("usernames.txt"):
    user = user.strip()
    r = requests.post(URL, data={"username": user, "password": "wrong-pw-123"})
    # fingerprint: whatever distinguished a real account in step 2
    if "Invalid password" in r.text:
        valid.append(user)
print("valid accounts:", valid)

Swap the fingerprint check for status code, length, or a timing threshold depending on which oracle the target exposes.

Fixing it

  • Return one identical, generic message for every failed login, reset, and signup, so nothing distinguishes a bad username from a bad password.
  • Equalize timing by performing the same password-hash work whether or not the account exists.
  • Keep status codes, redirects, and response lengths identical across both cases.
  • Rate-limit and monitor the high-volume request pattern that enumeration produces.

Full defensive detail is in securing authentication. Next, turn your list of valid accounts into access with password-based attacks, or enumerate a real target on a free lab.

← Back to Authentication Vulnerabilities: The Complete Practical Guide