Quick answer
Rate limiting restricts how many requests a given client may make within a period of time, and rejects or delays anything beyond that. It is the simplest effective defence against brute-force login attempts, scraping, API abuse and small floods, because it makes each attempt cost the attacker time regardless of how the request is disguised. The hard parts are choosing what counts as a client and setting a limit that stops abuse without breaking legitimate bursts.
01The common algorithms
Token bucket is the usual choice for public APIs because real clients are bursty: a page load may fire ten requests at once and then nothing for a minute. A strict per-second cap punishes that pattern; a bucket absorbs it while still limiting the sustained rate.
A worked example: a bucket that holds ten tokens and refills at one per second lets a client send ten requests at once, then one a second after that. Ten quiet seconds later it can burst again. nginx's limit_req module uses the closely related leaky-bucket method, in which its burst parameter plays the part of the bucket size.
| Algorithm | How it behaves | Trade-off |
|---|---|---|
| Fixed window | Counts requests per calendar interval, resets at the boundary | Simple, but allows a double burst across the boundary |
| Sliding window | Counts over the trailing N seconds | Accurate, more state to keep |
| Token bucket | Tokens refill at a steady rate; each request spends one | Allows short bursts while capping the sustained rate |
| Leaky bucket | Requests drain at a fixed rate; a queue absorbs bursts up to a limit | Smooths traffic, but queued requests wait instead of being rejected |
02What to count per: the decision that matters most
A rate limit is only as good as its key. Limit per IP address and you punish everyone behind a shared connection: an office, a university, a mobile carrier's NAT, an entire country in some cases. Shared addresses are one of the two usual reasons real visitors hit a limit such as Cloudflare's error 1015; the other is a rule that counts every image and script on a page as a separate request.
IPv6 brings the opposite problem. One subscriber is normally given a whole prefix, a /48 or /56 under the RIPE community's recommendations, so a per-address limit can be dodged by rotating through addresses the client already owns. Key IPv6 limits on a prefix rather than a single address; the /64 is the smallest sensible unit.
- Per IP: the default; fine for anonymous traffic, unfair to shared connections
- Per API key or account: much fairer, and possible wherever the client authenticates
- Per username on login routes: credential stuffing spreads attempts over many addresses, so a per-IP limit alone misses it
- Per session: works for logged-in users, and forces an attacker to log in before abusing anything
- Per IP and endpoint: a tight limit on /login with a loose one everywhere else
- Per subnet or prefix: for abuse spread across many addresses in one network; a subnet calculator shows how many addresses a /24 or a /48 really covers
03Choosing limits per route
The right limit follows from what a route costs you and who calls it: strict and keyed on username and address for login and password reset, per API key for a public API, moderate for search, and usually none for cached static assets. Measure normal traffic first, set the limit comfortably above its busiest legitimate peak, and run the rule in a dry-run mode before enforcing it. A route-by-route table, with what each route should answer when its limit is hit, is in designing limits that spare real users.
04A worked example in nginx
The configuration below puts a strict limit on the login route and a loose one on everything else. limit_req answers with 503 unless you set limit_req_status, which makes a rate limit look like an outage to clients and monitoring alike (see 503 Service Unavailable), and it adds no Retry-After header by itself; what the response should carry is covered below.
With burst=5 and nodelay, the first six login attempts from one address pass at once and later ones are rejected until the bucket drains at five a minute. Before enforcing a new limit, limit_req_dry_run on counts what would have been rejected without rejecting anything. And $binary_remote_addr is only the visitor's address if you have restored the client IP behind a reverse proxy; otherwise every visitor shares the proxy's bucket. Connection limits, timeouts and the inheritance rules that catch people out are covered in nginx DDoS protection.
# http {} context
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;
limit_req_zone $binary_remote_addr zone=site:10m rate=10r/s;
limit_req_status 429;
server {
listen 80;
server_name example.com;
location = /login {
limit_req zone=login burst=5 nodelay;
proxy_pass http://127.0.0.1:8080;
}
location / {
limit_req zone=site burst=40 nodelay;
proxy_pass http://127.0.0.1:8080;
}
}05Measuring before you limit
Your access logs already hold the numbers you need. In the common combined log format the client address is the first field and the request path the seventh, so two lines of awk show the heaviest clients on a route and their busiest minute. Run them over a normal week, not over an attack, and set the limit above what real users do.
# Requests per client to /login
awk '$7 ~ /^\/login/ {print $1}' access.log | sort | uniq -c | sort -rn | head
# Busiest minute per client on /login
awk '$7 ~ /^\/login/ {print $1, substr($4, 2, 17)}' access.log | sort | uniq -c | sort -rn | head06Responding well when a limit is hit
Answer a per-client limit with 429 Too Many Requests (RFC 6585) and a Retry-After header (RFC 9110), not 503, so callers slow down instead of assuming an outage. Well-written clients honour Retry-After and back off; without it they retry immediately and make the problem worse. In nginx, a named error location adds the header to rejections only, as shown in how site owners should send a 429; the same guide covers how clients should back off when the header is missing.
For public APIs, it is worth returning the limit state on every response rather than only on rejection, so clients can pace themselves before they hit the wall. There is no finished standard yet: the IETF's RateLimit and RateLimit-Policy fields are still an Internet-Draft, and many APIs use their own x-ratelimit-* headers, as GitHub does. Whichever you choose, document it.
HTTP/1.1 429 Too Many Requests
Content-Type: application/json
Retry-After: 20
RateLimit-Policy: "default";q=100;w=60
RateLimit: "default";r=0;t=2007What rate limiting cannot do
A rate limit assumes an attacker is constrained by how fast one client can go. A distributed attack removes that assumption: ten thousand machines in a botnet, each making two requests a second, will pass any sane per-client limit while delivering twenty thousand requests a second to your origin.
This is why rate limiting is a layer rather than a solution. It handles brute force, scraping and single-source abuse very well. Distributed floods need DDoS mitigation that reasons about the traffic as a whole.
Questions
Frequently asked questions
Sources
Sources and verification
nginx configuration checked with nginx -t and run locally with nginx 1.22.1, confirming six immediate logins followed by 429; awk commands run on a synthetic combined-format log with GNU Awk 5.2, Debian 12, October 2026.
- 01RFC 6585: Additional HTTP Status Codes (429 Too Many Requests) · IETF
- 02RFC 9110: HTTP Semantics, section 10.2.3 Retry-After · IETF
- 03RateLimit header fields for HTTP (Internet-Draft) · IETF
- 04Module ngx_http_limit_req_module · nginx.org
- 05Rate limits for the REST API · GitHub Docs
- 06RIPE-690: IPv6 prefix assignment for end-users · RIPE NCC
Last updated