Server

Rate Limiting in Nginx: limit_req, limit_conn, and burst

By Prabath Thalangama· October 24, 2026· 3 min read
#nginx#security#performance

Introduction

Rate limiting is the cheapest defence against credential stuffing, scrapers, and accidental client loops. Nginx's built-in limit_req is a leaky bucket — effective once you understand burst and nodelay, confusing before.

Concepts

  • limit_req — limits the request rate (requests per second/minute) per key (usually client IP).
  • limit_conn — limits concurrent connections per key. Good against slow downloads and connection floods.

Both need a shared-memory zone declared in the http {} block to track state across worker processes.

The leaky bucket

rate=10r/s means one request is allowed every 100 ms. Requests arriving faster than that are:

  • rejected (default), or
  • queued up to burst=N slots and released at the rate (adds latency), or
  • served immediately if burst=N nodelay — up to N excess requests go through now, then the bucket must drain before more are allowed.

nodelay is almost always what you want for a web app: allow a short spike, then throttle. Plain burst (no nodelay) is for smoothing a backend that truly can't handle bursts.

Hands-on

Protect a login endpoint

http {
    # 10 MB zone (~160k IPs), 5 requests/minute per IP
    limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;

    # General API: 20 r/s, allow a burst of 40
    limit_req_zone $binary_remote_addr zone=api:10m rate=20r/s;

    # Max 10 concurrent connections per IP
    limit_conn_zone $binary_remote_addr zone=perip:10m;

    limit_req_status 429;          # not the default 503
    limit_conn_status 429;
}

server {
    location /api/ {
        limit_req  zone=api burst=40 nodelay;
        limit_conn perip 10;
        proxy_pass http://backend;
    }

    location = /login {
        limit_req zone=login burst=3 nodelay;
        proxy_pass http://backend;
    }
}

$binary_remote_addr (4 or 16 bytes) is used instead of $remote_addr (up to 15 chars) so the zone holds more entries.

Behind a proxy / CDN

$binary_remote_addr will be your load balancer's IP — everyone shares one bucket. Trust the forwarded IP:

set_real_ip_from 10.0.0.0/8;        # your LB / CDN ranges
real_ip_header X-Forwarded-For;
real_ip_recursive on;
# now $binary_remote_addr is the real client

Whitelist trusted sources

geo $limit {
    default        1;
    10.0.0.0/8     0;               # internal
    203.0.113.7/32 0;               # partner
}
map $limit $limit_key {
    0 "";                            # empty key = not rate limited
    1 $binary_remote_addr;
}
limit_req_zone $limit_key zone=api:10m rate=20r/s;

An empty key disables limiting for that request.

Verification and troubleshooting

# Hammer it and watch the status codes
for i in $(seq 1 30); do curl -s -o /dev/null -w "%{http_code} " https://example.com/login; done; echo

# Nginx logs rejections at [error] level
sudo tail -f /var/log/nginx/error.log | grep limiting
#   limiting requests, excess: 3.000 by zone "login", client: 1.2.3.4
  • Legitimate users getting 429 — the rate is too low for real usage, or everyone shares one bucket because you're behind a proxy and didn't set real_ip. Check the client: IP in the error log — if it's your LB, that's the bug.
  • burst seems ignored — without nodelay, burst requests are delayed, not rejected; the client sees slow responses, not 429s. Add nodelay.
  • Limit not applying at all — the limit_req directive is in a location that isn't matching (see location matching), or the zone name is misspelled (Nginx would fail nginx -t though).
  • limit_req_zone errors: "zone already bound" — you declared two zones with the same name. Names must be unique across limit_req_zone, limit_conn_zone, etc.
  • Static assets throttled — your limit is on location / and catching everything. Scope it to /api/ and /login, or exclude static with a separate location.
  • Attack still gets through — a distributed attack from thousands of IPs defeats per-IP limits. Add limit_req on a coarser key ($http_authorization for API tokens, $request_uri for one endpoint) or push the problem to a WAF.
PrabathStuck on something this site can't fix?Reach out to Prabath directly on LinkedIn.