Skip to content

Rate limit requests in nginx

Severity: lowApplies to: nginx 1.25+Applies to: nginx 1.30 / 1.31Applies to: freenginx
The fix/etc/nginx/nginx.conf
http {
limit_req_zone $binary_remote_addr zone=general:10m rate=20r/s;
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;
limit_req_status 429;
server {
location / {
limit_req zone=general burst=40 nodelay;
}
location /login {
limit_req zone=login burst=3 nodelay;
}
}
}

Read this before deploying anything above.

$binary_remote_addr is the address of whoever opened the TCP connection. If nginx sits behind Cloudflare, an ALB, or any reverse proxy, that is the proxy’s address — not the user’s.

The result is the opposite of what you wanted. Every visitor shares one bucket, so a burst of legitimate traffic trips the limit for everybody at once, while an attacker is throttled no more than anyone else. You have built a self-inflicted outage and called it protection.

Fix it by teaching nginx which address to trust:

set_real_ip_from 10.0.0.0/8; # your load balancer's range
real_ip_header X-Forwarded-For;
real_ip_recursive on;

Then $binary_remote_addr becomes the client address and the limits mean something.

set_real_ip_from must list only proxies you actually control. It tells nginx to believe a header, and X-Forwarded-For is trivially forged. Trust it from an arbitrary source and an attacker sets their own address per request, giving themselves an unlimited rate — a rate limiter that any client can opt out of.

Rate limiting is not an access control. It closes no exposure and stops no attacker who has a valid credential or a botnet with many addresses.

What it genuinely buys you:

  • Origin protection. Your PHP-FPM pool or upstream app has a finite worker count; nginx can absorb far more concurrency than it can. limit_req keeps a crawler from exhausting the thing behind it.
  • Slower credential stuffing. 5r/m on /login makes online password guessing impractical. That’s real, and it’s why the login zone is separate.

What it does not buy you: protection from a real DDoS (that has to be absorbed upstream of your server, by a network that can hold the volume), or a substitute for authentication.

The two parameters everyone gets wrong:

  • rate=20r/s is enforced per millisecond internally — 20r/s means one request every 50ms, not 20 arriving together. Without burst, a page loading 15 assets at once gets rejected immediately.
  • burst=40 allows a queue of 40 beyond the rate.
  • nodelay serves that burst immediately rather than spacing it out. Without nodelay, excess requests are delayed to fit the rate, which turns a fast page into a slow one and looks like your server is broken.

For web traffic you almost always want burst with nodelay. Without both, the limiter fires on ordinary page loads.

limit_req_status 429 is worth setting: nginx returns 503 by default, which tells clients and CDNs “server error” rather than “you are being rate limited”. 429 is the correct code and well-understood by every HTTP client.