Rate limiting by IP is the first defence against credential stuffing, scraping and runaway clients. It's also easy to get wrong in a way that looks fine in testing:
- Everyone shares one bucket. Behind Nginx, a load balancer or a CDN, the app sees the proxy's address. After a handful of requests from anyone, everyone gets
429 Too Many Requests. - Attackers get unlimited buckets. The "fix" for problem 1 is often to key on
X-Forwarded-For's first entry, or to set Express'strust proxytotrue. Both let a client send a different made-up IP with every request and never hit the limit. - IPv6 users get 2^64 buckets. One IPv6 customer typically has at least a
/64, which is 18 quintillion addresses. Limiting per full address limits nothing.
Below is a setup for Express (with express-rate-limit v8) and for Nginx (limit_req) that avoids all three. We ran every config here against real requests and show the results.
Step 0: get the client IP right
A rate limiter is only as good as the address it counts. Before touching limits:
- Express: set
trust proxyto the addresses of your proxies, for example'loopback'for Nginx on the same host. Nevertrue. See the Express client IP guide. - Nginx: restore the client address with the
realipmodule, trusting only your load balancer or CDN ranges. See real client IP behind Cloudflare. - Both: make sure the app or Nginx can't be reached around the proxy. Why that matters is in X-Forwarded-For spoofing.
Express: express-rate-limit v8
express-rate-limit is the standard middleware. A few things changed in recent major versions that older tutorials miss:
maxwas renamed tolimitin v7 (maxstill works).- The default key is the client IP passed through
ipKeyGenerator, which groups IPv6 addresses by subnet. Theipv6Subnetoption defaults to 56 (a/56). - It validates your setup at runtime and logs errors such as
ERR_ERL_PERMISSIVE_TRUST_PROXY(you settrust proxytotrue) andERR_ERL_UNEXPECTED_X_FORWARDED_FOR(anX-Forwarded-Forheader arrived buttrust proxyis off). Read your logs after deploying: these two catch problems 1 and 2 above.
import express from 'express';
import { rateLimit, ipKeyGenerator } from 'express-rate-limit';
const app = express();
app.set('trust proxy', 'loopback'); // Nginx on the same host; see Step 0
app.use(express.json());
// General API limit: 100 requests per minute per client
const apiLimiter = rateLimit({
windowMs: 60 * 1000,
limit: 100,
standardHeaders: 'draft-8', // RateLimit / RateLimit-Policy headers
legacyHeaders: false, // no X-RateLimit-* headers
});
// Login: 10 attempts per 15 minutes per IP *and* account
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
limit: 10,
standardHeaders: 'draft-8',
legacyHeaders: false,
keyGenerator: (req) => `${ipKeyGenerator(req.ip)}|${String(req.body?.email ?? '').toLowerCase()}`,
message: { error: 'Too many login attempts. Try again later.' },
});
app.use('/api', apiLimiter);
app.post('/login', loginLimiter, (req, res) => {
// ... check credentials ...
res.json({ ok: true });
});
app.listen(3000, '127.0.0.1');This is what it did with Express 5.3 and express-rate-limit 8.7, behind a proxy on loopback, with the limit lowered to 3 for the test:
| Requests | Result |
|---|---|
4 from 198.51.100.9 | 200, 200, 200, 429. The 429 carries Retry-After: 60 |
then 1 from 198.51.100.10 | 200: a separate bucket |
4 from different /64s inside 2001:db8:1::/56 | 200, 200, 200, 429: one bucket for the /56 |
X-Forwarded-For: 9.9.9.9, 198.51.100.9 (forged first entry) | 429: still counted as 198.51.100.9 |
The response headers with draft-8 look like RateLimit: "3-in-1min"; r=0; t=60 and RateLimit-Policy: "3-in-1min"; q=3; w=60; …. Well-behaved clients can slow down before they hit the wall.
Custom keys: always wrap req.ip
If you write a keyGenerator that uses the IP, pass it through ipKeyGenerator(req.ip), as in the login limiter. Returning req.ip directly gives every IPv6 address its own bucket, and the library warns with ERR_ERL_KEY_GEN_IPV6. Want a different IPv6 grouping? Set ipv6Subnet (the docs suggest values between 32 and 64) or pass it as the second argument: ipKeyGenerator(req.ip, 64).
Keying login attempts on IP and account means a shared office IP doesn't lock out everyone at once, while one attacker still can't hammer one account. Add a separate per-account limit (key on the email alone) if you also worry about distributed guessing against one user.
Skipping trusted callers
import { BlockList } from 'node:net';
const noLimit = new BlockList();
noLimit.addSubnet('192.0.2.0', 24); // office, uptime monitor...
const apiLimiter = rateLimit({
windowMs: 60 * 1000,
limit: 100,
standardHeaders: 'draft-8',
legacyHeaders: false,
skip: (req) => noLimit.check(req.ip),
});Skip by address, never by a header the client can set ("X-Internal: 1").
More than one process or server
The default MemoryStore lives inside one Node.js process. It resets on restart, and each process counts separately, so four cluster workers allow four times the limit. With more than one process, use one of the shared stores listed in the express-rate-limit docs (Redis, Memcached and others) so all instances count together.
Nginx: limit_req
Limiting in Nginx stops abusive traffic before it costs your application anything. The limit_req module implements a leaky bucket per key. The full http {} part:
http {
# 1. Restore the client address. Trust only your load balancer / CDN.
set_real_ip_from 10.0.0.0/16; # your load balancer subnet
real_ip_header X-Forwarded-For;
real_ip_recursive on;
# 2. Addresses that are never limited
geo $rate_limit_exempt {
default 0;
192.0.2.0/24 1; # e.g. office / monitoring
}
map $rate_limit_exempt $limit_key {
0 $binary_remote_addr;
1 ""; # empty key = not counted
}
# 3. Zones: 10 MB is roughly 80,000 addresses on 64-bit systems
limit_req_zone $limit_key zone=per_ip:10m rate=10r/s;
limit_req_zone $limit_key zone=login:10m rate=5r/m;
limit_req_status 429;
limit_req_log_level warn;
server {
# ... listen, server_name, TLS ...
location / {
limit_req zone=per_ip burst=20 nodelay;
proxy_pass http://127.0.0.1:3000;
}
location = /login {
# limit_req is only inherited when a level has none, so list both here
limit_req zone=login burst=5 nodelay;
limit_req zone=per_ip burst=20 nodelay;
proxy_pass http://127.0.0.1:3000;
}
}
}We ran this with Nginx 1.24, with set_real_ip_from pointed at the test client, firing requests concurrently:
| Traffic | Result |
|---|---|
| 40 simultaneous requests from one client | 21 × 200 (1 + burst=20), 19 × 429 |
10 more from the same client, each with a different forged first X-Forwarded-For entry | 10 × 429: the forgery doesn't create new buckets |
| 5 from another client | 5 × 200 |
| 40 from the exempt range | 40 × 200 |
8 sequential /login from one client | 6 × 200, then 429 |
What each piece does
$binary_remote_addris the client address in 4 or 16 bytes. Use it rather than$remote_addrto save memory. Afterrealipit's the client's address, not the load balancer's.- Zone size. From the docs: one megabyte holds about 16.000 states on 32-bit platforms or 8.000 on 64-bit. Size the zone for the number of distinct addresses you expect in a window. When it's full, Nginx evicts the least recently used state.
rate+burst+nodelay.rate=10r/sis the sustained rate.burst=20lets a client go 20 requests over it, for page loads with many assets or API clients that batch.nodelayserves those burst requests immediately instead of spacing them out. Withoutnodelay, excess requests are delayed to fit the rate, which ties up connections.limit_req_status 429. The default is503, which tells clients and monitors that your server is broken.429tells them to slow down.- Empty key = not limited. Nginx doesn't count requests whose key is empty. The
geo+mappair uses that to exempt addresses cleanly. - Inheritance. A
locationwith its ownlimit_reqdoesn't inherit the server-level ones. Repeat every zone you want to apply there.
Two gotchas we hit while testing
returnbypasseslimit_req. Our first test config answered withreturn 200 "ok";and never got a429.returnruns in Nginx's rewrite phase, before the phase wherelimit_reqruns. Limits apply to requests that get past the rewrite phase (proxy_pass,fastcgi_pass, static files), not to responses produced byreturn.- Sequential
curlwon't trip a per-second limit. A shell loop is slower than you think. Test with concurrent requests (ab,hey,wrk, or a few lines of Node) or temporarily use a per-minute rate.
Rolling out safely
Turn on limit_req_dry_run on; (Nginx 1.17.1+) first. Requests are counted but not rejected, and with limit_req_log_level warn the would-be rejections appear in the error log as limiting requests … by zone "per_ip". Watch a normal day of traffic, adjust rate and burst, then remove the dry run.
IPv6 in Nginx
limit_req keys on the full IPv6 address, and Nginx has no built-in way to key on a /64 or /56. A client with a large IPv6 allocation can rotate addresses to get around a per-address limit. Use Nginx limits as the coarse outer layer, and do the subnet-aware limit in the application (ipKeyGenerator above), or with a scriptable proxy (OpenResty/njs) if you need it at the edge.
Which layer should limit?
| Concern | Nginx limit_req | App middleware |
|---|---|---|
| Floods and scrapers | Best: rejected before the app works | Works, but the app still accepts the connection |
| Login / signup / password reset | Good coarse limit | Best: can key on account, email, API key |
| IPv6 subnets | Full address only | /56 or /64 grouping |
| Per-plan or per-API-key quotas | No | Yes |
| Several app servers | One shared count per Nginx instance | Needs a shared store |
Most production setups use both: a generous Nginx limit per IP, plus tighter, smarter limits in the app on the endpoints that matter.
Beyond single IPs
Some abuse comes from many addresses in one network, such as a cloud provider or a hosting company. Limiting per network (ASN) catches it without punishing residential users. ASN lookup by IP shows an Express limiter keyed on the ASN from GET https://ipapi.gr/api/lookup/{ip}?key=…, with a fallback for "No Data". If the noise is from crawlers, check first whether they're verified bots you want to keep: see how to verify AI crawler IPs.
FAQ
What status code and headers should a rate-limited response have?
429 Too Many Requests with Retry-After (seconds until the client may retry). express-rate-limit sends both. Nginx sends the status (with limit_req_status 429) but no Retry-After. If your clients use it, route the 429 to a named location inside the server block:
error_page 429 = @too_many;
location @too_many {
add_header Retry-After 1 always;
return 429 "Too many requests\n";
}Should I rate limit by IP at all if users share addresses?
Yes, with limits generous enough for a busy office or a mobile carrier's NAT, and tighter limits keyed on something more specific (account, API key, session) where you have it. Per-IP limits are the fallback for anonymous traffic.
My limits suddenly hit everyone at once. Why?
Almost always the IP has become the proxy's address again: a new load balancer subnet that isn't in trust proxy or set_real_ip_from, or a CDN in front of an origin that only trusted the load balancer. Log the address the limiter sees and compare it with the client's real IP.