Rate limiting login and APIs with nginx limit_req: burst, nodelay, 429, fail2ban
Set up nginx limit_req for login and API routes with correct burst and nodelay semantics, see what a 100-per-minute script really gets, and why per-account limits matter.
- rate=5r/m returns one slot every 12 seconds; burst=5 nodelay lets 6 through at once and answers the rest with 429
- A script sending 100 requests a minute from one IP gets 10 through in the first minute, then 5 a minute
- Per-IP limits are not enough for login; count failures per account in the app, because attackers rotate IPs
- Set limit_req_status 429 with Retry-After, and let fail2ban ban IPs that keep hitting the limit
Any login page or API on the internet gets hammered by scripts from day one: password guessing, credential stuffing with passwords leaked elsewhere, and bulk scraping. A rate limit says “this much per client per unit of time, no more”. This article builds two layers, nginx per IP and the application per account, adds fail2ban on top, and works through the numbers to show what the settings really do.
The config targets Ubuntu 24.04, nginx 1.24.0 and fail2ban 1.0.2 (the Ubuntu 24.04 package versions). The meaning of burst and nodelay was checked against the nginx docs and the formula in ngx_http_limit_req_module.c; the worked numbers come from that formula.
How limit_req works
nginx runs a leaky bucket per key (usually the client IP) and stores a counter of “excess” requests:
- Every incoming request adds +1 to excess
- Excess drains over time at
rate. nginx tracks this at millisecond granularity, sorate=5r/mmeans one slot back every 60 ÷ 5 = 12 seconds, not “5 per minute, then reset” - If excess after adding would exceed
burst, the request is rejected and not counted
| Setting | Meaning |
|---|---|
rate=5r/m | Average allowed rate; below 1 per second, write it as r/m |
burst=5 | Up to 5 requests may accumulate above the rate. Default 0 |
no nodelay | Burst requests are delayed to leave at the rate |
nodelay | Burst requests pass immediately, but slots still free at the rate |
delay=N (1.15.7+) | The first N pass immediately, the rest of the burst is delayed |
limit_req_status | Status sent on rejection; default 503 |
503 means “server unavailable”, which is the wrong message. Set 429 Too Many Requests from RFC 6585: the client sent too many requests, and caches must not store the response.
Worked example: a script at 100 requests a minute
With rate=5r/m burst=5 nodelay, one IP hits the login route 100 times a minute, every 0.6 seconds:
- A slot frees every 12 s, so 0.6 s drains 0.6 ÷ 12 = 0.05
- Request 1 (t=0) passes, excess = 0
- Requests 2 to 6 (t=0.6 to 3.0) each add 1 and drain 0.05: excess goes 0.95, 1.9, 2.85, 3.8, 4.75. None exceeds 5, all pass: 6 in total (1 + burst)
- Request 7 (t=3.6) would make excess 5.7, over 5: 429
- To pass again, excess must drop from 4.75 to 4 or less: 0.75 × 12 = 9 seconds after t=3.0. The next request after that point arrives at t=12.6 and passes
- From then on one passes roughly every 12 seconds (t=24.6, 36.6, 48.6, …)
| Period | Passed | Got 429 |
|---|---|---|
| First minute | 10 | 90 |
| Each minute after | 5 | 95 |
| A whole day (per IP) | ≈ 5 × 1,440 = 7,200 | everything else |
These figures match a millisecond-by-millisecond simulation of the formula in the nginx source. The conclusion: per IP, the script is cut to 5%. But an attacker with 1,000 IPs still gets 7,200 × 1,000 = 7.2 million tries a day. The nginx limit is only the first layer.
nginx config
Zones are declared in the http context. On Ubuntu a file in conf.d works, since nginx.conf includes it inside http:
# /etc/nginx/conf.d/ratelimit.conf
# 10m holds about 80,000 IPs on 64-bit (1 MB ≈ 8,000 states of 128 bytes)
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
limit_req_status 429;
# inside the site's server { }
error_page 429 = @ratelimited;
location = /api/login {
limit_req zone=login burst=5 nodelay;
proxy_pass http://127.0.0.1:3000;
}
location /api/ {
limit_req zone=api burst=20 nodelay;
proxy_pass http://127.0.0.1:3000;
}
location @ratelimited {
add_header Retry-After 60 always;
default_type application/json;
return 429 '{"error":"too_many_requests"}';
}
nginx does not add Retry-After on its own, so rejections go to a named location that sets it. 60 seconds is deliberately longer than the 12 seconds a slot takes, so well-behaved clients back off. One trap: a location with its own add_header inherits none from the server level. If you use security headers, include them again here (details in security headers on nginx).
Test before reloading:
sudo nginx -t && sudo systemctl reload nginx
If you worry the limits will hit real users, turn on limit_req_dry_run on; (since 1.17.1) for a few days. nginx counts and logs but does not block, so you can see who would have been limited.
Layer two: per-account limits in the app
nginx only sees IPs; it does not know which account a request targets. OWASP advises tying the failed-login counter to the account, not the IP, so it adds up no matter how many IPs the attacker rotates through. A Python + Redis example counting failures in a 15-minute window:
import redis
r = redis.Redis()
WINDOW = 15 * 60 # seconds
MAX_FAILS = 10
def _key(username: str) -> str:
return f"loginfail:{username.strip().lower()}"
def login_blocked(username: str) -> int:
"""Seconds to wait; 0 means the attempt may proceed."""
k = _key(username)
if int(r.get(k) or 0) >= MAX_FAILS:
return max(r.ttl(k), 1)
return 0
def record_failure(username: str) -> None:
k = _key(username)
p = r.pipeline()
p.set(k, 0, ex=WINDOW, nx=True) # start a new window if none exists
p.incr(k)
p.execute()
def record_success(username: str) -> None:
r.delete(_key(username))
In the login handler, if login_blocked() returns more than 0, answer 429 with Retry-After set to that value. Keep the error message identical whether the password was wrong, the account does not exist, or it is locked; otherwise attackers use the differences to find which accounts exist.
Comparing with the earlier numbers:
| Measured per | Max tries per day |
|---|---|
| One IP through nginx at 5r/m | ≈ 7,200 |
| 1,000 IPs through nginx | ≈ 7,200,000 |
| One account, 10 per 15 min | 10 × (1,440 ÷ 15) = 10 × 96 = 960, regardless of IPs |
NIST SP 800-63B sets a ceiling of 100 consecutive failures on one account before that authenticator is disabled; you can stack that rule on top using the same Redis counters (putting Redis in the right place).
nginx limits volume; the app limits guessing against one account. You need both, either alone leaves a gap.
Layer three: fail2ban for repeat offenders
nginx rejects the requests, but the script keeps sending them, costing CPU and log space. fail2ban reads nginx’s error log for lines like limiting requests, excess: ... by zone "..." (logged at error level, the default limit_req_log_level) and bans at the firewall. It ships a nginx-limit-req filter, so you only enable the jail:
# /etc/fail2ban/jail.d/nginx-limit-req.local
[nginx-limit-req]
enabled = true
# Ubuntu 24.04 defaults to systemd, but nginx logs to a file
backend = auto
port = http,https
logpath = /var/log/nginx/error.log
findtime = 10m
maxretry = 10
bantime = 1h
sudo systemctl restart fail2ban
sudo fail2ban-client status nginx-limit-req
The backend = auto line matters. Ubuntu 24.04’s jail.d/defaults-debian.conf sets backend = systemd under [DEFAULT], which reads the journal instead of files. nginx’s error log is a file, so without the override the jail runs and never bans anyone.
In plain terms: an IP that collects more than 10 rejections in 10 minutes is banned from ports 80 and 443 for an hour. Keep maxretry well above the burst so a real user who clicks twice is never caught. If you already run fail2ban for SSH as in hardening SSH, just add this jail.
Common mistakes
- Behind a proxy without realip. Every request comes from the proxy’s IP, the whole site shares one quota, and fail2ban bans the proxy. Set
set_real_ip_fromto the trusted proxy ranges andreal_ip_headerto what the proxy sends (realip is built into Ubuntu’s nginx package) - Reading 5r/m as “5 a minute, then reset”. It is one every 12 seconds; without burst, a user who mistypes twice in a row is blocked sooner than you expect
- No burst on an API the frontend calls in parallel. A page firing 8 requests within 50 ms at
rate=10r/swith no burst gets only some of them through limit_reqin both server and location. A location inheritslimit_reqfrom the level above only if it has none of its own; one in the location silences the server-level one there- Leaving 503. Monitoring counts it as an outage, and clients cannot tell “wait” from “broken”
- Permanent account locks. Anyone can lock anyone out. Use temporary locks or growing delays, and keep password reset working
Sources
- Module ngx_http_limit_req_module — nginx.org
- Module ngx_http_realip_module — nginx.org
- ngx_http_limit_req_module.c — nginx source on GitHub
- RFC 6585 §4: 429 Too Many Requests — RFC Editor
- NIST SP 800-63B-4 §3.2.2 Rate Limiting — NIST
- Authentication Cheat Sheet — OWASP
- nginx-limit-req filter — fail2ban on GitHub
FAQ
What changes without nodelay?
Without nodelay, requests over the rate but within burst are delayed so they leave at the configured rate. At 5r/m the sixth request can wait a minute and a real user sees a hanging page. With nodelay, burst requests pass immediately while slots still free up at the same rate, which is why login and API routes usually use it.
My site is behind Cloudflare or a load balancer. What do I change?
Configure the realip module so nginx sees the client's real address. Otherwise $binary_remote_addr is the proxy's IP, every user shares one quota, and fail2ban bans your own proxy.
Should I lock accounts after 5 failures?
A permanent lock lets anyone lock out anyone else by typing wrong passwords. OWASP advises balancing threshold, window and duration, for example growing delays or temporary locks, and keeping password reset available.
Commands and settings were tested on sample systems. Try them on a test machine before production.