Understanding Brute-Force and Credential Stuffing Attacks
Brute-Force and Credential Stuffing: Two Different Attacks Requiring Different Defenses
Brute-force and credential stuffing are both password-guessing attacks, but they differ fundamentally in their approach and the defenses that work against them.
Brute-Force Attacks
A brute-force attack systematically tries many passwords against a single account:
- Online brute-force: The attacker sends login requests directly to the application, trying passwords from a dictionary or generating them systematically.
- Offline brute-force: The attacker obtains a password hash database and cracks it locally using GPU clusters. No interaction with the application occurs.
Online brute-force characteristics:
- Targets a single account with many password attempts
- Slow — limited by network latency and server response time
- Detectable — generates many failed login attempts from a single source
- Defended by: rate limiting, account lockouts, CAPTCHA
Offline brute-force characteristics:
- Not detectable by the application — no login attempts are made
- Extremely fast — billions of guesses per second on GPU clusters
- Defended by: strong password hashing (Argon2id, bcrypt), password complexity requirements
Credential Stuffing
Credential stuffing is an attack where an attacker replays username and password pairs leaked from other services:
- The attacker obtains a list of credentials from a data breach (e.g., from Have I Been Pwned or dark web marketplaces)
- They use automated tools to try these credential pairs on many different websites
- The attack exploits password reuse — users who use the same password across multiple services
Credential stuffing characteristics:
- Targets many accounts with a single password attempt each (or a few attempts per account)
- Fast — distributed across thousands of IP addresses to avoid rate limits
- Hard to detect — each IP makes only a few requests, appearing as normal login attempts
- Defended by: MFA, breach password detection, device fingerprinting, bot detection
Why Rate Limiting Alone Is Not Enough
Rate limiting is effective against online brute-force but insufficient against credential stuffing:
- Brute-force: One IP, many attempts → rate limiting catches it
- Credential stuffing: Many IPs, few attempts each → rate limiting misses it
A comprehensive defense requires multiple layers:
| Layer | Brute-Force | Credential Stuffing |
|---|---|---|
| Per-account rate limit | Effective | Partially effective |
| Per-IP rate limit | Effective | Ineffective (distributed) |
| CAPTCHA | Effective | Partially effective |
| MFA | Effective | Effective |
| Breach password check | Ineffective | Effective |
| Bot detection | Effective | Effective |
| Device fingerprinting | Effective | Effective |
Implementing Rate Limiting and Progressive Lockouts
Practical Rate Limiting and Lockout Strategies
Per-Account Rate Limiting
Limit the number of failed login attempts per account within a time window:
import time
from collections import defaultdict
# Simple in-memory rate limiter (use Redis in production)
failed_attempts = defaultdict(list)
MAX_ATTEMPTS = 5
WINDOW_SECONDS = 300 # 5 minutes
def is_account_locked(username):
now = time.time()
attempts = failed_attempts[username]
# Remove attempts outside the window
attempts[:] = [t for t in attempts if now - t < WINDOW_SECONDS]
return len(attempts) >= MAX_ATTEMPTS
def record_failed_attempt(username):
failed_attempts[username].append(time.time())
def login(username, password):
if is_account_locked(username):
return {'error': 'Account temporarily locked. Try again later.'}, 429
if verify_password(username, password):
failed_attempts[username].clear() # Reset on success
return {'status': 'success'}
else:
record_failed_attempt(username)
return {'error': 'Invalid credentials'}, 401
Per-IP Rate Limiting
Limit the number of login requests per IP address:
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
limiter = Limiter(
key_func=get_remote_address,
default_limits=["200 per day", "50 per hour"]
)
@app.route('/login', methods=['POST'])
@limiter.limit("5 per minute") # Strict limit for login endpoint
def login():
# ...
Progressive Lockouts
Instead of a hard lockout (which creates a denial-of-service vector), use progressive delays:
def get_delay(failed_count):
"""Exponential backoff based on failed attempt count."""
if failed_count <= 3:
return 0 # No delay for first 3 attempts
return min(2 ** (failed_count - 3), 30) # Max 30 seconds
def login(username, password):
user = get_user(username)
if user:
delay = get_delay(user.failed_login_count)
if delay > 0:
time.sleep(delay) # Slow down the response
if verify_password(password, user.password_hash):
user.failed_login_count = 0
return success()
else:
user.failed_login_count += 1
return failure()
Why progressive lockouts are better than hard lockouts:
- Hard lockout: After 5 failed attempts, the account is locked for 30 minutes. An attacker can deliberately lock out any user (denial of service).
- Progressive delay: Each failed attempt increases the response time. The attacker is slowed down but legitimate users are not locked out.
CAPTCHA Integration
After a threshold of failed attempts, require a CAPTCHA:
def login(username, password):
user = get_user(username)
if user and user.failed_login_count >= 3:
if not verify_captcha(request.form.get('captcha_token')):
return {'error': 'CAPTCHA required'}, 403
# ... proceed with login
Breach Password Detection
Check new passwords against known breach corpora (e.g., Have I Been Pwned's Pwned Passwords API):
import hashlib
import requests
def is_pwned_password(password):
"""Check if password appears in known data breaches using k-anonymity."""
sha1 = hashlib.sha1(password.encode()).hexdigest().upper()
prefix, suffix = sha1[:5], sha1[5:]
response = requests.get(f'https://api.pwnedpasswords.com/range/{prefix}')
hashes = response.text.splitlines()
for h in hashes:
if h.split(':')[0] == suffix:
return True # Password found in breach
return False
This uses k-anonymity — only the first 5 characters of the hash are sent to the API, so the full password is never transmitted.
Practice: Design a Rate Limiting Strategy
Design a rate limiting strategy for a login endpoint that:
- Limits each account to 5 failed attempts per 15 minutes
- Limits each IP to 20 login attempts per minute
- Requires CAPTCHA after 3 failed attempts
- Does not permanently lock any account
Practice Problems
An attacker obtains a list of 10 million username/password pairs from a data breach. They use a botnet of 10,000 IP addresses to try these credentials on your login endpoint, making 100 requests per minute from each IP. Explain why per-IP rate limiting alone is insufficient and what additional defenses you would implement.
Solution
Per-IP rate limiting is insufficient because the attack is distributed across 10,000 IPs, each making only 100 requests per minute — well below typical per-IP rate limits. Each IP tries 100 different username/password pairs, so the total attempt volume is 1 million requests per minute, but no single IP triggers a rate limit.
Additional defenses:
1. **MFA**: Even if credentials are correct, the attacker cannot complete login without the second factor
2. **Breach password detection**: Check passwords against known breach corpora and reject or flag them
3. **Bot detection**: Analyze request patterns (timing, headers, JavaScript execution) to distinguish bots from humans
4. **Device fingerprinting**: Track devices and flag suspicious patterns (many accounts from one device)
5. **Per-account rate limiting**: Limit failed attempts per account across all IPs (not per-IP)
6. **CAPTCHA**: Require CAPTCHA for login attempts from suspicious sources Implement a progressive delay system for failed login attempts. The delay should increase exponentially: 0s for the first 3 attempts, then 2s, 4s, 8s, 16s, with a maximum of 30s.
Solution
```python
import time
def get_delay(failed_count):
if failed_count <= 3:
return 0
return min(2 ** (failed_count - 3), 30)
def login(username, password):
user = get_user(username)
if not user:
# Use constant-time comparison to prevent username enumeration
dummy_hash = '$argon2id$v=19$m=65536,t=3,p=4$...'
verify_password(password, dummy_hash)
return {'error': 'Invalid credentials'}, 401
delay = get_delay(user.failed_login_count)
if delay > 0:
time.sleep(delay)
if verify_password(password, user.password_hash):
user.failed_login_count = 0
user.save()
return {'status': 'success'}
else:
user.failed_login_count += 1
user.save()
return {'error': 'Invalid credentials'}, 401
# Delay progression:
# Attempt 1-3: 0s delay
# Attempt 4: 2s delay
# Attempt 5: 4s delay
# Attempt 6: 8s delay
# Attempt 7: 16s delay
# Attempt 8+: 30s delay (capped)
```
This slows brute-force attacks exponentially while not locking out legitimate users who occasionally mistype their password. Quiz
1. What is the key difference between brute-force and credential stuffing attacks?
2. Why are progressive delays preferred over hard account lockouts?
3. Which defense is most effective against credential stuffing attacks?
4. What is the purpose of checking passwords against breach corpora (e.g., Have I Been Pwned)?
5. Why is a dummy hash comparison used when a username does not exist?
Flashcards
Question
What is the difference between brute-force and credential stuffing?
Click to reveal answer
Answer
Brute-force targets one account with many password attempts. Credential stuffing replays leaked username/password pairs across many accounts. Rate limiting defends against brute-force; MFA and breach password detection defend against credential stuffing.
Question
Why are progressive delays preferred over hard lockouts?
Click to reveal answer
Answer
Hard lockouts create a denial-of-service vector — an attacker can deliberately lock out any user. Progressive delays slow attackers exponentially without locking out legitimate users.
Question
What is the most effective defense against credential stuffing?
Click to reveal answer
Answer
Multi-factor authentication (MFA). Even if the attacker has the correct username and password, they cannot complete login without the second factor. Breach password detection and bot detection are effective supplements.
Question
What is k-anonymity in breach password checking?
Click to reveal answer
Answer
A privacy-preserving technique where only the first 5 characters of the password's SHA-1 hash are sent to the API. The API returns all hashes with that prefix, and the client checks locally. The full password is never transmitted.
Question
Why use a dummy hash comparison for non-existent usernames?
Click to reveal answer
Answer
To prevent username enumeration. Without it, login attempts for non-existent usernames return immediately, while existing usernames take longer (hash computation). The timing difference reveals valid usernames.
Revision Notes
Key Takeaways
- 1. Brute-force targets one account with many passwords; credential stuffing targets many accounts with leaked credentials
- 2. Rate limiting is effective against brute-force but insufficient against distributed credential stuffing
- 3. Progressive delays (exponential backoff) are preferred over hard lockouts to prevent denial-of-service
- 4. MFA is the most effective defense against credential stuffing
- 5. Use dummy hash comparison for non-existent usernames to prevent username enumeration
Interview Tips
- • Explain the difference between brute-force and credential stuffing with concrete examples of each
- • Discuss why progressive delays are superior to hard lockouts — focus on the denial-of-service risk
- • Describe a multi-layered defense strategy that addresses both attack types
- • Connect rate limiting to the OWASP Top 10 (A07: Identification and Authentication Failures) and explain how it fits into a comprehensive authentication security strategy
Cheat Sheet
Brute-Force Defenses and Rate Limiting Cheat Sheet
Phase: Password Storage and Session Security | Level: intermediate
Attack Types:
- Brute-force: one account, many passwords → rate limiting effective
- Credential stuffing: many accounts, leaked credentials → MFA, breach detection effective
Defense Layers:
- Per-account rate limit (5 failed attempts per 15 minutes)
- Per-IP rate limit (20 requests per minute)
- Progressive delays (exponential backoff: 2s, 4s, 8s, 16s, max 30s)
- CAPTCHA after 3 failed attempts
- MFA for all accounts
- Breach password detection (Have I Been Pwned k-anonymity API)
- Bot detection and device fingerprinting
Key Principles:
- Progressive delays > hard lockouts (prevents denial-of-service)
- Dummy hash comparison for non-existent usernames (prevents username enumeration)
- Rate limiting alone is insufficient against distributed credential stuffing
Key points:
- Per-account rate limits raise the cost of online guessing against a single username
- Progressive lockouts must avoid becoming a denial-of-service vector against real users
- Credential stuffing lists from other breaches are blocked by MFA more than by lockouts
Tags: passwords, rate-limiting
Continue Learning
Before You Start
- No prerequisites listed.