</>CodeWithKarani

IP Rate Limiting Won't Stop Credential Stuffing: What Actually Does

Karani GeoffreyKarani Geoffrey10 min read

Your fail2ban jail is clean. The nginx limit_req zone has not tripped in days. The Cloudflare dashboard is green. And yet support is forwarding you a fresh batch of "someone logged into my account and changed my email" tickets, and your fraud numbers are climbing. Nothing in your defences fired, because nothing you built was watching the thing that is actually happening.

Here is the uncomfortable truth: per-IP rate limiting does almost nothing against a real credential-stuffing attack. It is excellent against a script hammering one login form from one server. Modern stuffing does not look like that. It looks like ten thousand residential IP addresses each trying one username and one password, once, and then never coming back. Every single request is comfortably under any threshold you would dare to set on a login endpoint, because setting it lower would lock out your actual users on shared mobile carrier NAT.

The fix is not a better threshold. It is to stop keying your defence on the attacker's cheapest, most disposable resource - the IP address - and start keying it on the things that are expensive for them to change: the target account, the validity of the password itself, and a second factor.

IP throttling cannot see a distributed attack because each IP stays under the limit. Add these layers instead:

  • Per-account failure tracking so 500 failed attempts against one username trigger a lockout or step-up, no matter how many IPs they come from.
  • Breached-password screening at signup and password change using the Have I Been Pwned range API, so stuffed credentials do not work even when the guess is "correct".
  • CAPTCHA after N failures and MFA as step-up challenges, not blanket friction.
  • Keep fail2ban and limit_req - they still stop the dumb attacks - but never treat them as your credential-stuffing defence.

What credential stuffing actually looks like in your logs

There is no single error string for this one. That is exactly why it slips past. If you grep your access log by IP you see nothing alarming:

102.89.x.x   - POST /login 401  1 attempt
41.90.x.x    - POST /login 401  1 attempt
197.156.x.x  - POST /login 200  1 success
105.112.x.x  - POST /login 401  1 attempt

Every line is a different IP. Every IP appears once or twice. Your limit_req zone=login burst=5 rule never sees five requests from the same address, so it never delays anything. fail2ban's maxretry = 5 never counts to five for any single host, so it never bans. From the per-IP point of view, this is just a slightly busy Tuesday.

Now pivot the same log by username instead of IP and the attack is obvious:

user=alice@example.com   412 failed logins from 389 distinct IPs in 6 hours
user=bob@example.com     377 failed logins from 355 distinct IPs in 6 hours
user=admin@example.com   1,904 failed logins from 1,610 distinct IPs

The attack was always visible. You were just looking at the wrong axis.

Why per-IP limiting is structurally blind to this

Rate limiting works by counting events in a window and rejecting the ones over a threshold. The question is: count events per what? nginx's limit_req, fail2ban, and most WAF rules count per source IP because that is the field they have cheapest access to. That choice is the entire vulnerability.

A credential-stuffing operation buys access to a residential proxy pool - hundreds of thousands of real home and mobile IP addresses, rented by the gigabyte. The tooling rotates to a fresh IP on every request. So the attacker's per-IP rate is 1. To catch a per-IP rate of 1 you would have to set your limit to 0, which blocks everyone.

Worse, IP addresses are shared by your legitimate users in ways that punish you for tightening the screw. An entire Kenyan mobile network can sit behind a handful of carrier-grade NAT addresses. A university, an office, a co-working space - all one IP to you. Every notch you turn the per-IP limit down to inconvenience the attacker locks out a real crowd of people first. The attacker has more IPs than you have thresholds.

Same attack, two ways to count it Counting per IP (blind) Threshold: 5 / minute per IP 102.89.x.x count 1 PASS 41.90.x.x count 1 PASS 197.156.x.x count 1 PASS ... 1,610 more IPs count 1 PASS Nothing ever reaches 5. No block fires. Counting per account (sees it) Threshold: 20 failures / 15 min per user user = admin@example.com 102.89.x.x +1 41.90.x.x +1 197.156.x.x +1 ... all summed to one counter 1,904 > 20 Step-up / lockout fires on attempt 21.
The requests are identical. Only the key you count against changed - and that is the whole game.

The fix, in layers

No single control stops credential stuffing. You are building a stack where the attacker has to beat every layer at once, and each layer raises their cost. Here is the order I add them in for a client, cheapest and highest-impact first.

Step 1: Track failures per account, not just per IP

This is the layer that actually addresses the distributed problem, so it goes first. Keep a counter keyed on the normalised username being targeted, in Redis so it is shared across all your app servers and expires cleanly.

import redis

r = redis.Redis(host="127.0.0.1", port=6379, db=0)

WINDOW_SECONDS = 900          # 15 minutes
STEP_UP_AFTER = 10            # require CAPTCHA / MFA challenge
LOCK_AFTER = 50              # temporary account protection

def record_failed_login(username: str) -> int:
    key = f"login_fail:{username.strip().lower()}"
    count = r.incr(key)
    if count == 1:
        r.expire(key, WINDOW_SECONDS)   # start the window on first miss
    return count

def clear_on_success(username: str) -> None:
    r.delete(f"login_fail:{username.strip().lower()}")

Now the login handler decides based on the account's recent history, regardless of which IP the current request came from:

def check_login(username: str, password: str) -> str:
    fails = int(r.get(f"login_fail:{username.strip().lower()}") or 0)
    if fails >= LOCK_AFTER:
        return "locked"           # protect the account, notify the owner
    if fails >= STEP_UP_AFTER:
        return "challenge"        # force CAPTCHA + MFA before checking password

    if not verify_password(username, password):
        record_failed_login(username)
        return "denied"
    clear_on_success(username)
    return "ok"

Two details that matter. Normalise the username (strip().lower()) or the attacker evades the counter with Admin@ vs admin@. And on a real lockout, protect the account and email its owner rather than telling the attacker "you found a valid username, keep going" - the response for a locked account and an unknown account should look the same.

Step 2: Reject breached passwords so a correct guess still fails

Credential stuffing works because people reuse passwords, so a password leaked from some other site is often the real password here too. You can neutralise a huge share of the attack by refusing to let anyone set a password that already appears in a breach corpus. The Have I Been Pwned Pwned Passwords range API lets you check this without ever sending the password anywhere, using k-anonymity: you send the first 5 characters of the SHA-1 hash, and it returns every matching suffix with a breach count.

import hashlib
import requests

def times_pwned(password: str) -> int:
    sha1 = hashlib.sha1(password.encode("utf-8")).hexdigest().upper()
    prefix, suffix = sha1[:5], sha1[5:]
    resp = requests.get(
        f"https://api.pwnedpasswords.com/range/{prefix}",
        headers={"Add-Padding": "true"},   # hides the real response size
        timeout=3,
    )
    resp.raise_for_status()
    for line in resp.text.splitlines():
        hash_suffix, _, count = line.partition(":")
        if hash_suffix == suffix:
            return int(count)              # > 0 means it is in a breach set
    return 0

Only the 5-character prefix leaves your server. The API cannot know which of the ~800 returned suffixes you were asking about. Wire it into signup and password change: if times_pwned(pw) > 0, reject with "this password has appeared in a data breach, choose another". This is exactly the mechanism NIST 800-63B recommends over the old expiry-and-complexity rules that produced Password1! everywhere.

Do not check it on every login attempt against the submitted password - that adds an external HTTP call to your hottest path and leaks timing. Check it when a password is chosen.

Step 3: CAPTCHA as a step-up, not a toll booth

A CAPTCHA on every login punishes your real users and trains them to hate you, and modern solving services defeat blanket CAPTCHAs cheaply anyway. Use it as a conditional challenge, triggered by the signals from Step 1: once an account (or a fingerprint, below) crosses the failure threshold, require the challenge before the password is even checked. The attacker's automation now has to solve a CAPTCHA per targeted account, which is where their per-attempt cost jumps from near-zero to cents, and cents times millions is a business decision they may decline to make.

Step 4: Device fingerprinting to catch the pattern IPs hide

Since the IP rotates, key a second counter on something that rotates less: a coarse device fingerprint (user-agent, accept-language, TLS/JA3 signature, screen and timezone from the client). Attacker tooling is often far more uniform than real traffic here - thousands of "different" users with a byte-identical fingerprint is itself the signal. Treat fingerprint like IP: useful as an additional counter and a risk input, never as a sole identity, because it is spoofable and legitimate users share them too.

Step 5: MFA, because it makes a correct password insufficient

Everything above raises the attacker's cost. MFA changes the payoff: even a valid, non-breached, correctly-guessed password does not grant access without the second factor. It is the only layer that makes a successful credential guess worthless on its own. You will not force it on every user on day one, but at minimum step it up for high-value accounts and for any login that tripped the risk signals above.

Each layer costs the attacker something different Per-IP limit + fail2ban - stops single-host brute force. Blind to distribution. Per-account failure tracking - sees the attack no matter how many IPs. Breached-password screening - a reused stolen password stops working. CAPTCHA step-up after N failures - per-attempt cost jumps from ~0 to cents. MFA - even a correct password is not enough. The only layer that does this.
Remove any one row and the attacker routes around the gap. The stack is the defence, not any single control.

Verification: prove the account layer actually fires

You do not want to discover on incident night that your counter never armed. Test it deliberately against a throwaway account. Fire failed logins from different source IPs (or spoof X-Forwarded-For in a controlled test if your app trusts it) at one username, and watch the Redis key climb:

# hammer one username with wrong passwords
for i in $(seq 1 12); do
  curl -s -o /dev/null -w "%{http_code}\n" \
    -X POST https://app.example.com/login \
    -d "username=canary@example.com&password=wrong$i"
done

# inspect the per-account counter directly
redis-cli GET "login_fail:canary@example.com"

You expect the counter to reach 12, and the HTTP responses to change once you cross STEP_UP_AFTER (the endpoint should start returning the challenge, not a plain 401). Then confirm a genuine successful login clears it:

redis-cli GET "login_fail:canary@example.com"   # -> (nil) after a real success

For the breached-password layer, a one-line check proves the wiring end to end - the string password should return a very large count:

python3 -c "from app.security import times_pwned; print(times_pwned('password'))"
# expect a number in the millions, not 0

What people get wrong

"fail2ban handles our brute force." It handles brute force. Credential stuffing is not brute force - brute force is many guesses against one account from few sources, stuffing is few guesses against many accounts from many sources. fail2ban counts per host and per host the attacker is invisible. Keep it for the noisy attacks it does stop, but it is not in this fight. If you have not tuned it, my write-up on fail2ban silently not banning after an update is worth a read, because a jail you think is protecting you and is not is worse than none.

"We just block the attacking countries / ASNs." Residential proxy pools are inside your own users' networks, on the same mobile carriers and ISPs your customers use. Geo-blocking a credential-stuffing pool means geo-blocking your customers. You will lose more real logins than attacks.

"We subscribe to a threat-intel IP blocklist." The whole point of a residential proxy pool is that the IPs are freshly rotated, mostly clean home connections, and burned faster than any list updates. Blocklists catch known-bad infrastructure. This attack deliberately runs on known-good infrastructure.

"CAPTCHA on the login page fixes it." Blanket CAPTCHA is defeated by cheap solving services and mostly taxes your real users. As a conditional step-up it is valuable; as a permanent toll booth it is friction the attacker has already priced in.

"We just lower the per-IP limit." This is the trap. There is no per-IP threshold that blocks a rate of 1 without also blocking every real user behind carrier NAT. Lowering it is how you turn a security incident into a self-inflicted outage.

When it is still broken

If accounts are still being taken over after you have layered the above, work through these:

  • Your account counter is keyed on the wrong value. If you count by IP inside the "per-account" function, or forget to normalise the username, the counter never accumulates. Verify with the redis-cli GET check above during a live attempt.
  • The attacker is targeting the password-reset or OTP flow, not login. Stuffing defences on /login do nothing for /forgot-password or an SMS-OTP endpoint. Every credential-accepting and code-accepting endpoint needs the same per-account and step-up logic. An unthrottled OTP verify endpoint is its own brute-force target.
  • There is no rate limit on the reverse proxy at all, so even the dumb attacks get through. The account layer is the new work, but you still want the baseline. My nginx reverse proxy, TLS and rate limiting config covers the limit_req baseline, and surviving a bot flood on a cheap VPS covers ordering fail2ban and Cloudflare correctly.
  • The breaches predate your screening. Screening only blocks newly-chosen breached passwords. Existing users may already have one. Run times_pwned across a sample at next login (against the submitted password, once, then force a reset if it hits) or prompt high-value accounts to rotate.
  • Your login response leaks whether the username exists. Different status, timing, or wording for "unknown user" versus "wrong password" hands the attacker a free username-validity oracle that makes their stuffing far more efficient. Make both paths return the same thing in the same time.

If you run more than a toy service, this is not optional hardening, it is table stakes. The attacker's entire advantage is that you are counting the one resource they can rotate for free. Take that advantage away and the economics of the attack collapse. For the broader server-side picture, my security checklist for self-hosted servers puts this in context with the rest of the surface.

Frequently asked questions

Why doesn't fail2ban stop credential stuffing?
fail2ban counts failed attempts per source IP and bans an address once it crosses maxretry. Credential stuffing spreads across thousands of rotating residential proxy IPs, each trying a username only once or twice, so no single IP ever reaches the ban threshold. fail2ban still stops single-host brute force, but it is structurally blind to a distributed attack. You need per-account tracking to see it.
Can't I just lower my per-IP rate limit to block stuffing?
No. In a distributed attack each IP makes a rate of one request, so the only per-IP limit that blocks it is zero, which blocks everyone. Real users share IP addresses behind mobile carrier NAT, universities and offices, so every notch you lower the limit locks out a crowd of legitimate people before it inconveniences the attacker. Count per account instead of per IP.
How do I check if a password has been breached without sending it to a third party?
Use the Have I Been Pwned Pwned Passwords range API with k-anonymity. Compute the SHA-1 of the password, send only the first 5 hex characters of the hash, and the API returns every matching hash suffix with its breach count. You match the remaining suffix locally, so the full password and full hash never leave your server. Check it when a password is chosen, at signup and password change, not on every login.
What is the single most effective layer against account takeover?
MFA, because it is the only control that makes a correct password insufficient on its own. Every other layer raises the attacker's cost or catches the pattern, but a valid guessed password still works without them. With MFA enforced, a successful credential guess is worthless without the second factor. Pair it with per-account throttling and breached-password screening so you are not relying on any single control.
#Credential Stuffing#Rate Limiting#Authentication#fail2ban#Redis#MFA
Keep reading

Related articles