Why Per-IP Rate Limits Fail Against Rotating IPs (and What to Count Instead)
One login, 100 rotating IPs: a 10-per-minute-per-IP limit let 275 of 275 requests through. How to spot rotation in your traffic and what to key your limits on.
Why Per-IP Rate Limits Fail Against Rotating IPs (and What to Count Instead)
A per-IP rate limit is the first one everyone adds, and it's the right default for anonymous endpoints. It also has a blind spot that scrapers have exploited for years: if every request comes from a different address, no address ever reaches the limit.
We measured it on our lab app, the companion to Academy Episode 9. Here is what happened, how to spot it in your own traffic, and what to count instead.
The experiment
Northstar's notes export (GET /api/notes/export) returns everything a user has written. Someone was pulling it, so we added the obvious rule: ten exports per minute, per address.
npx securenow ratelimit add --route /api/notes/export --mode exact --method GET --limit 10 --window 1m
ACTIVE GET exact:/api/notes/export at 10/60s per ip
Then the scraper did what scrapers do: one signed-in session, a new address on every request, cycling through 100 of them (198.51.100.100 to .199). In the next minute:
TOTAL rotating-scraper: {"200":275}
275 exports, all successful. Not one 429.
Why the limit never fired
The arithmetic is simple. About 245 requests a minute spread over 100 addresses is roughly 2.5 per address per minute. Our coding agent confirmed it from the traffic: the busiest single address sent 3 in a minute. A limit of 10 per address can't trip on that, and lowering it to 3 would hit real users first.
Rotation is cheap:
- Residential and datacenter proxy pools rent thousands of addresses by the hour.
- Cloud functions and serverless egress give a different source address per invocation.
- IPv6 hands a single customer billions of addresses.
- Forged
X-Forwarded-Forheaders, if your app trusts them from the wrong place. That's what our lab did: the app trusted proxy headers from loopback, so a client connecting locally could claim any address it liked. Our agent flagged it as a code fix.
How to spot rotation in your traffic
Rotation has a shape. Each address looks innocent; the group doesn't:
| Signal | What you see |
|---|---|
| Many addresses, each under the limit | hundreds of IPs at 1–3 requests a minute on one route |
| One client | the same user agent and the same header set on every request |
| One identity | the same session cookie or API key behind many addresses |
| A steady pace | requests every ~250 ms, around the clock |
| Zero limits firing | firewall hits shows no 429 on the route being pulled |
You can ask your coding agent to check this with SecureNow's MCP tools. The prompt we used, after the per-IP rule failed:
Someone is still pulling the notes export on lab-auth production, even though we put a
10-per-minute-per-IP rate limit on it. Using SecureNow, find out why that limit isn't
stopping it, and propose a rate limit that will, without changing any code.
Don't apply anything yet.
It read the rule list and the firewall hits (zero 429s), pulled the traces, and ran forensic queries grouping the export by minute, client address and user agent. Its report:
Export requests, 15:05–15:08: 865 (about 245/min), all 200
Client IPs: 100, at most 3/min each
User agents: 1 (node)
429s from your rate limit: 0
It also noted what it couldn't prove: session cookies are redacted in telemetry, so "one login behind many addresses" was still an inference.
What to count instead
Count by something the client can't rotate cheaply:
| Traffic | Count by | SecureNow flag |
|---|---|---|
| Signed-in web app | the session cookie | --cookie <session-cookie-name> |
| Public or partner API | the API key header | --header x-api-key |
| Anonymous endpoints (login, signup, reset) | the address | default (--key-by ip) |
| One expensive endpoint | everyone, as one budget | --key-by global |
For the export, the agent proposed counting by the session cookie, and we approved it:
npx securenow ratelimit add --route /api/notes/export --mode exact --method GET \
--limit 10 --window 1m --cookie lab_auth --duration 2h
Six seconds after the rule went live, the 429s started. Firewall hits then answered the question the agent couldn't before:
rule: Export: 10 per minute per session · keyBy cookie
hits: 60 distinctIps: 60
Sixty different addresses hit the same limit within twelve seconds. They all shared one session. From then on the scraper got ten exports a minute instead of 245, and the scraper's own log shows the change:
15:12:03 200:46
15:12:13 200:26 429:21
15:12:23 429:49
15:12:33 429:49
The cookie value is hashed before it's used as a bucket key and never stored. A request without the cookie falls back to per-IP counting, so dropping the cookie doesn't help an attacker.
Layer your limits
One key rarely covers everything. A sensible set for a signed-in app:
# anonymous entry points: per address
npx securenow ratelimit from-text "rate limit POST /api/auth/login to 5 attempts per minute per IP" --yes
npx securenow ratelimit from-text "and no more than 20 login attempts per hour per IP on POST /api/auth/login" --yes
# signed-in data access: per session
npx securenow ratelimit add --route /api/notes/export --mode exact --method GET --limit 10 --window 1m --cookie connect.sid
# API clients: per key
npx securenow ratelimit add --route /api/v1 --mode prefix --limit 600 --window 1m --header x-api-key
A global ceiling can backstop a session rule if attackers rotate sessions too (creating accounts is harder than rotating addresses, but not impossible), at the cost that one client can use up the shared budget for everyone. If sign-up itself is the abuse, block or limit it directly: see Block an IP, a range or one route without a deploy.
Close the forged-address hole
If your app trusts X-Forwarded-For from anyone who can reach it, every IP-based control is a suggestion. Two rules:
- Keep the app port reachable only through your proxy or load balancer. Bind to localhost or a private network, and don't publish the port.
- Trust exactly your proxies. In Express,
app.set('trust proxy', 1)trusts one hop; nevertrueon an app that can be reached directly. In SecureNow, the SDK readsX-Forwarded-Foronly when the connection comes from a private, loopback or configured proxy address; add public proxy addresses toconfig.networking.trustedProxiesin.securenow/runtime.json.
Related
- Academy Episode 9: Rate limits outside your code
- Rate limit per API key, per session or per IP in Node.js
- Let your coding agent block attackers and add rate limits over MCP
- The $3,400 egress bill: a 72-hour scraping post-mortem
- How to stop a distributed bot attack: a response playbook
- Express rate limiting: production setup, Redis and proxies
Frequently Asked Questions
Can a scraper really bypass a per-IP rate limit?
Yes. If each request comes from a different address, no single address ever reaches the limit. In our test, 100 rotating addresses sent about 245 requests a minute and none of them exceeded 3 a minute, so a 10-per-minute-per-IP limit never fired.
What should I rate limit on instead of IP?
Something the client can't rotate cheaply: the session cookie of a signed-in user, or the API key of an API client. Keep a per-IP limit for anonymous endpoints like login and signup, and add a global ceiling where one shared budget makes sense.
How do I know if my traffic uses rotating IPs?
Look for many addresses that each stay under your limit but share one user agent, one session or API key, and a steady pace. Firewall hits, traces and a forensic query by session or route show the pattern.
Can attackers fake their IP with X-Forwarded-For?
Only if your app trusts that header from them. SecureNow reads X-Forwarded-For only when the direct connection comes from a private, loopback or configured trusted proxy. Keep the app port reachable only through your proxy and configure trust explicitly.
Recommended reading
Add rate limits by route and method, counted per IP, per API key, per session cookie or globally, written in plain English and shared across servers with Redis.
Oct 27Failed-login spikes, 404 bursts, one IP trying many usernames: how to tell if your web app is under attack, confirm it in your logs, and act in hour one.
Oct 26Those /.env, /.git and wp-login.php hits are bots hunting leaked secrets. See which matter, check you expose nothing, and what to do if one returns 200.
Oct 26