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.

Oct 27, 2026·9 min read

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-For headers, 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:

SignalWhat you see
Many addresses, each under the limithundreds of IPs at 1–3 requests a minute on one route
One clientthe same user agent and the same header set on every request
One identitythe same session cookie or API key behind many addresses
A steady pacerequests every ~250 ms, around the clock
Zero limits firingfirewall 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:

TrafficCount bySecureNow flag
Signed-in web appthe session cookie--cookie <session-cookie-name>
Public or partner APIthe API key header--header x-api-key
Anonymous endpoints (login, signup, reset)the addressdefault (--key-by ip)
One expensive endpointeveryone, 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:

  1. 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.
  2. Trust exactly your proxies. In Express, app.set('trust proxy', 1) trusts one hop; never true on an app that can be reached directly. In SecureNow, the SDK reads X-Forwarded-For only when the connection comes from a private, loopback or configured proxy address; add public proxy addresses to config.networking.trustedProxies in .securenow/runtime.json.

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

Rate Limit per API Key, per Session or per IP in Node.js, Without Code Changes

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 27
How to Tell If Your Web App Is Under Attack (and What to Do)

Failed-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 26
Why Is My App Getting /.env and wp-login.php Requests?

Those /.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