Every authentication endpoint on the internet has rate limiting. 23andMe had rate limiting. The credential stuffing attack that exposed 6.9 million genetic profiles ran for months without triggering a single alert, because attackers never exceeded the per-IP limit.
Rate limiting protects the server against overload. It does not protect accounts against compromise. Attackers optimize for this gap: they keep per-IP volume low, rotate addresses freely, and accumulate attempts per account without any individual counter firing.
Rate Limiting Protects the Server, Not the Account
The central design error in most rate limiters is using the IP address as the key. IP is a server protection signal, not an account security signal. The most common Redis pattern is INCR rate:{client_ip}, which measures throughput, not compromise risk.
The OWASP Credential Stuffing Cheat Sheet documents the problem: bucketing by IP creates a per-attacking-IP limit, not a per-target-account limit. A botnet with 1,000 IPs can make 1,000 x threshold attempts against the same account while each individual IP stays within the limit. The correct defense requires two independent buckets: one per IP to detect sweeps and DoS attacks, and one per account to detect targeted credential stuffing.
Implementing only the per-IP bucket solves the wrong problem. The server is protected. The account is not.
Rate limiting by IP is the correct instrument for DoS mitigation, where the raw volume from an address is the attack itself, and for sweep detection, where IP identity is the relevant signal. The failure mode appears when using the same key for per-account business logic limits: the attacker distributes volume across IPs, each IP looks innocent, and the per-account counter is never consulted.
X-Forwarded-For Hands Attackers a Free IP Rotation Button
Trusting proxy headers without validating that they arrive from a known and trusted proxy IP hands attackers a per-request rate limit reset. The bypass is trivial:
curl -H "X-Forwarded-For: 127.0.0.1" https://target.com/api/login \
-d '{"email":"victim@example.com","password":"guess"}'
HackerOne #1067533 (Courier, disclosed in 2021) documented the direct impact: adding X-Forwarded-For: 127.0.0.1 reset the counter on every request, enabling unlimited OTP brute force and account takeover. HackerOne #723974 (Moneybird) confirmed the same pattern on the password reset endpoint, rotating the header value per request to completely bypass the limit.
GHSA-688j-rm43-5r8x (Coolify) showed that the X-Forwarded-Host variant produces the same result on the login endpoint. GHSA-c2r5-cfqr-c553 (Mastodon) recorded the bypass enabling unlimited requests on an open-source platform with millions of active users.
The fix requires one rule with no exceptions: trust forwarding headers only when the request originates from an IP explicitly listed as a trusted proxy. When no proxy is configured, use REMOTE_ADDR as the rate limit key. Any other approach hands the attacker the choice of rate limiting key.
Race Conditions Break Rate Limiting Even with the Correct Key
The second dimensional failure is not in the chosen key, but in the atomicity of enforcement: the counter increments correctly by IP, but the window between check and write breaks the limit regardless of the key.
Non-atomic check-then-increment logic creates a TOCTOU (time-of-check to time-of-use) window where concurrent requests read a valid counter before any of them writes the updated value. CVE-2026-26206 (Wazuh, CVSS 6.5, published 2026-04-28, fixed in version 4.14.4) is the most recent and direct example: POST /security/user/authenticate used mutable module-level state without atomic synchronization. A concurrent burst allowed approximately twice the configured max_login_attempts before blocking was applied.
# TOCTOU: vulnerable (root cause of CVE-2026-26206)
count = redis.get(f"attempts:{account_id}")
if count and int(count) >= MAX_ATTEMPTS:
raise TooManyRequests()
redis.incr(f"attempts:{account_id}") # race window between get and incr
# Correct: atomic MULTI/EXEC
pipe = redis.pipeline()
pipe.multi()
pipe.incr(f"attempts:{account_id}")
pipe.expire(f"attempts:{account_id}", 900)
result = pipe.execute()
if result[0] > MAX_ATTEMPTS:
raise TooManyRequests()
The HTTP/2 single-packet attack amplifies this problem. Multiple requests queued in a single TCP window arrive before any counter is incremented. PortSwigger Web Security Academy documents accounts with a 3-attempt limit bypassed with 10 to 20 burst requests, all reaching the server before the block is written to shared state.
GraphQL Batching Makes Per-Request Counters Irrelevant
The third dimensional failure is the mismatch between logical operation and HTTP request: the IP key is counted once, but the work is executed N times.
When rate limiters count HTTP requests instead of logical operations, a single request with 100 aliased mutations represents 100 attack attempts at zero cost per additional attempt:
mutation {
login1: login(email: "victim@example.com", password: "pass1") { token }
login2: login(email: "victim@example.com", password: "pass2") { token }
login3: login(email: "victim@example.com", password: "pass3") { token }
login4: login(email: "victim@example.com", password: "pass4") { token }
login5: login(email: "victim@example.com", password: "pass5") { token }
}
Checkmarx documented a proof of concept with 100 login mutations in a single POST, bypassing a configured limit of 5 req/sec. All 100 attempts executed on the server from 1 request counted by the rate limiter. OWASP API2:2023 (Broken Authentication) lists GraphQL query batching explicitly as an authentication rate limiting bypass vector. CVE-2024-39895 (Directus, CVSS 6.5) confirms that aliased batching is exploitable for resource exhaustion in production systems.
The fix operates at the operation level: count mutations individually, disable batching on authentication endpoints, and treat the POST document as N logical operations for throttling purposes.
Distributed Attacks Win by Staying Below Any Threshold
Credential stuffing toolkits distribute requests across residential proxy networks so that per-IP volume never approaches the configured threshold. The 23andMe attack (October 2023) exposed 6.9 million genetic profiles. The arXiv paper 2502.04303 documents that the attack ran for months without triggering IP-based rate limits, because per-IP volume was deliberately kept low across an extensive pool of residential proxies.
The math is straightforward. With a limit of 10 req/min and a pool of 10,000 IPs, an attacker achieves 100,000 credential attempts per minute while each individual IP shows exactly 10 requests, within the configured limit. The per-IP detection window stays at zero alerts indefinitely. The OWASP Credential Stuffing Prevention Cheat Sheet cites tools like Sentry MBA with native residential proxy rotation, designed specifically to stay below per-IP thresholds at any attack scale.
The signal that per-IP rate limiting cannot see: an account receiving 50 login failures from 50 distinct IPs in 10 minutes. Each IP appears once. The account appears 50 times. The attack is invisible to the rate limiter and perfectly visible to a per-account counter.
What Real Detection Requires
Stopping attacks that bypass per-IP rate limiting requires behavioral anomaly scoring at the account dimension. This layer is not optional; it addresses the problem that per-IP rate limiting cannot solve architecturally.
Per-account bucketing is the primary signal. If account X receives 50 login failures from 50 distinct IPs in 10 minutes, the per-IP rate limiter records 1 attempt per IP. The per-account counter records the entire attack. This does not require machine learning, only the correct key in Redis and a per-account threshold in parallel to the per-IP threshold.
Behavioral baselines add the second layer: typical login time, geolocation velocity (impossible travel: same account, 2 countries, 3-minute difference) and ASN (Autonomous System Number, the IP block belonging to an organization, distinguishing residential proxy from datacenter) classification are signals that pure rate limiting cannot capture. Device fingerprinting raises the cost for the attacker without eliminating the vector: OS, browser version, and screen resolution are client-provided and spoofable. The value lies in increasing the spoofing cost as part of a defense-in-depth strategy, not as a single detection layer.
Translating these signals into actionable detection rules means mapping three specific patterns: the X-Forwarded-For header present on authentication endpoints without trusted proxy IP validation, account-level velocity anomalies that do not appear in any individual per-IP counter, and ASN classification of authentication traffic to separate residential proxies from datacenters. The relevant test surface goes beyond checking whether the counter fires: it evaluates whether the system measures the correct dimension.
Rate limiting is a necessary control against DoS and low-scale sweeps. Treating it as sufficient for account protection is the mistake that cost 23andMe 6.9 million exposed profiles and bankruptcy in March 2025. The fix is not a higher threshold: it is measuring the correct dimension, validating header trust at the edge, and applying per-account bucketing before the per-IP rate limiter is consulted.
Top comments (0)