Credential Stuffing and Password Spraying: Testing Authentication Resilience
Credential stuffing and password spraying account for a disproportionate share of real-world account takeovers — not because they're sophisticated, but because most applications don't have adequate defenses. Understanding how to test for these vulnerabilities is essential for any application security assessment.
This guide covers the attack mechanics, the specific controls you need to test, how to evaluate those controls without requiring a live attack, and what effective mitigations look like.
Attack Definitions and Mechanics
Credential Stuffing
Credential stuffing uses real username/password pairs from previous data breaches — millions of which are freely available in credential databases. The attacker feeds these pairs into the target application's login endpoint in bulk. Because users reuse passwords across services, a meaningful percentage of breach credentials will work on unrelated applications.
What makes this different from traditional brute force: the password list isn't guessed, it's known. Attacks are therefore targeted and can succeed even with strict lockout policies if the attacker distributes attempts across IPs and space out requests over time.
Password Spraying
Password spraying inverts the attack: instead of trying many passwords against one account, it tries one password (or a small set of common passwords) against many accounts. This approach deliberately avoids triggering account lockout thresholds. A single attempt per account per day against 10,000 accounts is undetectable by standard lockout policies.
Common spray lists: Password1!, Welcome123!, Company2026, Summer2026!, seasonal passwords, company-name-based passwords. These succeed far more often than they should.
What to Test
1. Rate Limiting on Authentication Endpoints
The most fundamental control. Without rate limiting, an attacker can attempt credentials as fast as the server will respond.
# Test rate limiting — send rapid successive requests and observe behavior
# (Use only against applications you have explicit authorization to test)
# Basic rate limit test with ffuf
ffuf -u https://target.com/auth/login \
-X POST \
-H "Content-Type: application/json" \
-d '{"email":"test@test.com","password":"FUZZ"}' \
-w /tmp/passwords.txt \
-mc 200,302,401 \
-rate 10 \
-o rate-test-results.json
# Observe: does the application slow down, return 429, or block after N attempts?
# Watch for: same HTTP 200 on attempt 100 as attempt 1 — no rate limiting
What to look for: rate limiting should trigger within 5–10 failed attempts from the same IP. The response should be a 429 Too Many Requests with a Retry-After header, or a temporary lockout with a clear message.
2. Account Lockout Logic
Account lockout (temporarily disabling an account after N failed attempts) is a standard brute force defense, but it must be implemented correctly. Test for:
- Lockout threshold — how many attempts before lockout triggers?
- Lockout duration — is it temporary (5 minutes) or permanent (until reset)?
- Lockout scope — does the lockout apply per-account, per-IP, or both?
- Lockout bypass — can you reset the counter by using a different IP, User-Agent, or X-Forwarded-For header?
# Test if X-Forwarded-For bypasses lockout
for i in {1..15}; do
curl -s -X POST https://target.com/auth/login \
-H "Content-Type: application/json" \
-H "X-Forwarded-For: 10.0.0.$i" \
-d '{"email":"victim@target.com","password":"wrong'$i'"}' \
-o /dev/null -w "Attempt $i: %{http_code}\n"
done
# Test if rotating User-Agent bypasses lockout
for agent in "Mozilla/5.0" "Chrome/120.0" "Safari/537.36"; do
curl -s -X POST https://target.com/auth/login \
-H "User-Agent: $agent" \
-d "email=victim@target.com&password=wrongpassword" \
-w "UA bypass attempt: %{http_code}\n"
done
3. CAPTCHA Effectiveness
CAPTCHAs are supposed to prevent automated submissions, but they're often only triggered after N failures, not on the first attempt. Test whether CAPTCHA is enforced:
- From the first unauthenticated login attempt
- Consistently (not just once, then skipped on subsequent requests)
- At the server side (not just validated client-side and trivially bypassable)
- Against mobile/API clients that don't receive the CAPTCHA widget
# Test if API login endpoint has CAPTCHA enforcement
# (Sometimes the web form has CAPTCHA but the API endpoint does not)
curl -s -X POST https://target.com/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"test@test.com","password":"wrongpassword"}' \
-w "\nHTTP: %{http_code}\n"
# Compare with web form endpoint
curl -s -X POST https://target.com/auth/login \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "email=test@test.com&password=wrongpassword" \
-w "\nHTTP: %{http_code}\n"
4. MFA Enforcement
MFA stops credential stuffing attacks cold — even with the correct password, the attacker can't complete authentication without the second factor. Test whether MFA is:
- Available to users (opt-in vs. mandatory)
- Enforced at the application level (not skippable by modifying the request)
- Protected against OTP brute force (rate limiting on the MFA code input)
- Not bypassable via remember-me tokens, backup codes with infinite validity, or race conditions
# Test if MFA step can be skipped by directly accessing post-login pages
# 1. Complete login with valid credentials up to MFA step
# 2. Without submitting MFA, try to access authenticated resources
curl -s https://target.com/dashboard \
-H "Cookie: partial_auth_session=SESSION_FROM_STEP1" \
-w "Status: %{http_code}\n" | head -5
# Test OTP rate limiting
for code in 000000 111111 123456 999999 123123; do
curl -s -X POST https://target.com/auth/mfa/verify \
-H "Content-Type: application/json" \
-H "Cookie: partial_auth=SESSION" \
-d "{\"otp\": \"$code\"}" \
-w "OTP $code: %{http_code}\n"
done
5. Response Differentiation (User Enumeration)
Applications that respond differently to "wrong username" vs "wrong password" enable attackers to build a valid username list before launching credential stuffing. See the authentication bypass testing guide for full user enumeration methodology.
6. Password Policy Enforcement
Weak password policies allow passwords that are in every credential breach database. Test that the application:
- Enforces minimum length (12+ characters, preferably 14+)
- Rejects passwords found in known breach databases (HIBP integration or similar)
- Enforces complexity or entropy requirements (not just character class rules)
- Prevents common patterns:
Company2026!,Password123!,Welcome1!
# Test if application accepts weak passwords during registration/reset
weak_passwords=("password" "Password1!" "Welcome123!" "Company2026" "Qwerty123!" "123456789")
for pwd in "${weak_passwords[@]}"; do
result=$(curl -s -X POST https://target.com/auth/register \
-d "email=testuser+$$@test.com&password=$pwd" \
-w "%{http_code}")
echo "Password '$pwd': $result"
done
7. Credential Stuffing-Specific Signals
Beyond lockouts, effective defense requires anomaly detection. Test whether the application generates any detectable signal when credential stuffing patterns occur:
# Does the application log failures? (Check for 401/403 in server logs during testing)
# Does it alert on high-volume failures from a single IP?
# Does it challenge unusual login patterns (new device, new country)?
# Test login from unusual User-Agent (simulates bot traffic)
curl -s -X POST https://target.com/auth/login \
-H "User-Agent: python-requests/2.31.0" \
-H "Content-Type: application/json" \
-d '{"email":"real-user@target.com","password":"correct-password"}' \
-w "\nHTTP: %{http_code}\n"
# Does the application challenge this login? Expect: MFA prompt or step-up auth
Controls Comparison
| Control | Stops Stuffing? | Stops Spraying? | Notes |
|---|---|---|---|
| Account lockout (per-account) | Partially | No | Spraying intentionally avoids lockout by limiting to 1 attempt/account |
| IP-based rate limiting | Partially | Partially | Bypassable with distributed IPs; stops unsophisticated attacks |
| CAPTCHA | Partially | Partially | Bypassable with 2captcha/anti-captcha services; adds friction |
| MFA enforcement | Yes | Yes | Correct password alone is not enough — strongest control available |
| Breach password rejection (HIBP) | Yes (on new passwords) | Partially | Prevents use of known-compromised passwords; doesn't help legacy accounts |
| Behavioral anomaly detection | Yes | Yes | Detects patterns invisible to per-request controls; requires monitoring infrastructure |
| Passkeys / WebAuthn | Yes | Yes | Phishing-resistant; eliminates the password credential entirely |
Testing Without Launching an Attack
In most pentest engagements, you want to validate defenses exist without actually executing a credential stuffing attack — which could lock out real users or generate an incident. Use these validation approaches:
Rate limit threshold testing
Create test accounts, then attempt logins with wrong passwords from a test account to find the lockout threshold. Use a dedicated test user, not a real user's account.
Policy review
Ask whether WAF rules exist for login endpoint flooding. Review application-side rate limiting configuration. Check whether logging and alerting is configured on authentication failures at the SIEM level.
Controlled single-IP test
From a single IP with authorization, send a controlled batch of failed logins (e.g., 20 attempts against a test account) and confirm that rate limiting triggers at the expected threshold and that the response is correct (429, lockout message, or CAPTCHA challenge).
Remediation Priorities
If you implement only one control: enforce MFA. Every other control reduces attack velocity or raises attacker cost. MFA invalidates the attack entirely — the correct password alone cannot authenticate. Push toward mandatory MFA for all accounts, or at minimum for privileged accounts and accounts with access to sensitive data.
Rate limiting implementation
Rate limit at the reverse proxy or CDN layer, not just the application layer — application-layer rate limiting can be saturated under load. Use a sliding window approach (not a fixed window that can be "reset" by timing requests to the window boundary). Apply limits per-IP and per-account independently, so distributed attacks still hit per-account limits.
# nginx rate limiting example
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;
location /auth/login {
limit_req zone=login burst=3 nodelay;
limit_req_status 429;
proxy_pass http://backend;
}
Breach password detection
Integrate with HaveIBeenPwned's k-anonymity API at password change and reset time. This checks new passwords against billions of known-compromised credentials without exposing the actual password to the API. Reject passwords that appear in breach databases.
# HIBP k-anonymity API — safe to call because only the first 5 chars of the SHA1 hash are sent
import hashlib
import requests
def password_in_breach(password: str) -> bool:
sha1 = hashlib.sha1(password.encode()).hexdigest().upper()
prefix, suffix = sha1[:5], sha1[5:]
response = requests.get(f"https://api.pwnedpasswords.com/range/{prefix}")
return suffix in response.text
Login anomaly alerting
Alert on: >5 failed logins per IP per minute, >3 failed logins per account per hour, successful login from a new country or device immediately after recent failed attempts, successful login with known-breach credential patterns (requires credential intelligence feeds).
Ironimo tests authentication endpoint rate limiting, user enumeration vectors, and lockout bypass conditions automatically — the same checks your pentester runs during an engagement, without requiring manual coordination. Starter plans from €149/month.
Join the waitlist for early access.
Start free scan