nginx rate limiting that slows bots without locking out people

A rate limit is only useful when it hits the right traffic. A bot that sends 200 requests a second should hit a wall, and a person who opens a page with 40 images should not. nginx's limit_req module does this with a leaky bucket per key, usually the client IP.

Three knobs

• rate: the steady pace allowed per key, such as 10r/s.
• burst: how many extra requests may queue when the bucket is full.
• nodelay: serve the burst straight away instead of spacing it out. Without it, a real browser loading a page waits for its own images.

Define the zones

Put the zones in the http context, for example /etc/nginx/conf.d/rate-limits.conf. $binary_remote_addr keeps the key small.

nginx
limit_req_zone $binary_remote_addr zone=general:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=login:10m   rate=1r/s;
limit_req_status 429;
limit_req_log_level warn;

Apply them to locations

Give general pages room and put the sign-in path under a tighter limit. Both locations proxy to the app as before.

nginx
server {
    listen 443 ssl;
    server_name example.com;

    location / {
        limit_req zone=general burst=40 nodelay;
        proxy_pass http://127.0.0.1:8020;
    }

    location = /login {
        limit_req zone=login burst=5;
        proxy_pass http://127.0.0.1:8020;
    }
}

Behind a CDN, restore the client address

If a CDN or load balancer sits in front, every request arrives from its own address and all visitors share one bucket. Restore the real client address first, using the provider's published ranges.

nginx
# http context; replace the range with your CDN's published ranges
set_real_ip_from 203.0.113.0/24;
real_ip_header   CF-Connecting-IP;

Test the limit

Thirty quick requests from one client should show a mix of 200 and 429 responses. Then count the 429s by address in the log.

bash
for i in $(seq 1 30); do
  curl -s -o /dev/null -w "%{http_code}\n" https://example.com/login
done | sort | uniq -c

sudo grep ' 429 ' /var/log/nginx/access.log | awk '{print $1}' | sort | uniq -c | sort -rn | head -10

Pick the numbers from data

Start loose and tighten. Check the 429 counts for a week, and look for addresses that hit the limit while doing ordinary things. If a real page trips it, raise the burst before you raise the rate.

Sign-in attempts need their own limits. osec Login Protection sets per-email and per-IP rules for sign-in codes.

Flow: request counted per IP, burst allowed, 429 when over the limit
Limit per IP, burst for people, 429 when over.

Sources

• nginx limit_req module: https://nginx.org/en/docs/http/ngx_http_limit_req_module.html
• nginx realip module: https://nginx.org/en/docs/http/ngx_http_realip_module.html