Rate Limiting #
When our application goes live on the internet, it immediately becomes exposed to all kinds of bots, automated web scrapers, and mass hacking attempts (brute-force attacks). One scenario that often cripples servers is when attackers flood CPU- and database-intensive endpoints (like product search pages, login authentication endpoints, or PDF invoice generation) with thousands of requests in seconds. Without protection, our application server quickly runs out of resources and crashes.
To protect the web server infrastructure from such traffic abuse, we must implement Rate Limiting. Nginx provides a very efficient and reliable built-in rate limiting feature using the ngx_http_limit_req_module module. In this article, we’ll thoroughly dissect how the rate limiting algorithm works, how to define shared memory zones, the use of critical parameters like burst and nodelay, customizing the HTTP 429 response code, and creating exceptions (whitelists) for trusted IP addresses.
How It Works: The Leaky Bucket Algorithm #
Nginx implements rate limiting using the Leaky Bucket algorithm. Let’s analogize this system for easier understanding:
Imagine a bucket with a small hole at the bottom.
- The water poured into the bucket represents the HTTP requests coming in from client browsers to the Nginx server.
- The water droplets coming out of the bottom hole represent requests allowed through by Nginx to the backend application at a constant, stable rate.
- The bucket itself acts as a buffer queue, which we call
burst. - The water overflowing the bucket’s rim represents excess requests that are immediately rejected and discarded by Nginx (returned as an error to the client).
Here’s the rate limiting decision logic flow in Nginx:
flowchart TD
Req["Client Request Arrives"] --> CheckZone{"Check IP Session Memory"}
CheckZone -->|Not Exceeding Rate| Allow["Allow Request<br>Forwarded to Backend"]
CheckZone -->|Exceeding Rate| CheckBurst{"Is There an Empty Slot in the Queue (Burst)?"}
CheckBurst -->|Yes, There's a Slot| Queue{"Put into Queue<br>(Processed with Delay)"}
CheckBurst -->|No Slots| Reject["Reject Request Instantly<br>(Return Status 429)"]
Queue -->|If Using Nodelay| Allow
Queue -->|Without Nodelay| Delay["Delay Processing According to the Queue"] --> Allow
classDef allow fill:#10b981,stroke:#059669,color:#ffffff;
classDef reject fill:#ef4444,stroke:#dc2626,color:#ffffff;
class Allow allow;
class Reject reject;Defining a Rate Limit Zone (limit_req_zone)
#
Before applying a rate limit to a specific endpoint, we must define a shared memory zone to track users’ request history. This zone must be defined at the http context level (outside the server block), because this tracking data must be shared and accessed in real-time by all Nginx worker processes.
Here’s the syntax for creating a rate limit zone:
http {
# Format: limit_req_zone [key] zone=[zone_name]:[memory_size] rate=[request_rate];
# Example 1: Limit per client IP - Rate of 10 requests per second
limit_req_zone $binary_remote_addr zone=limit_per_ip:10m rate=10r/s;
# Example 2: Limit per IP for login - Rate of 5 requests per minute
limit_req_zone $binary_remote_addr zone=limit_login:10m rate=5r/m;
}
Dissecting the limit_req_zone Directive Parameters:
#
- Key (
$binary_remote_addr): The Nginx variable used as a unique identifier for who gets limited.- Why
$binary_remote_addrand not$remote_addr? The$remote_addrvariable stores the client IP address as a plain text string (e.g.,192.168.100.250, which is 15 bytes). Meanwhile,$binary_remote_addrstores the IP address in binary representation (only taking 4 bytes for IPv4 and 16 bytes for IPv6). Using binary drastically saves server memory when handling hundreds of thousands of visitor IPs.
- Why
- Zone (
zone=limit_per_ip:10m): Determines our custom zone name (limit_per_ip) and the shared memory allocation capacity (10m= 10 Megabytes).- A memory allocation of
1m(1 Megabyte) can store about 16,000 unique IP data entries along with their access time statistics. With a size of10m, Nginx can track about 160,000 active IP addresses simultaneously. If memory fills up, Nginx automatically removes the oldest IP data.
- A memory allocation of
- Rate (
rate=10r/sorrate=5r/m): The allowed request rate limit.10r/s(10 requests per second): Nginx allows an average of 1 request every 100 milliseconds.5r/m(5 requests per minute): Nginx allows an average of 1 request every 12 seconds.
Applying Rate Limiting in a Location Block (limit_req)
#
After defining the zone at the http context, we can enable the restriction inside a server or location block using the limit_req directive.
Scenario 1: Strict Restriction Without Tolerance (No Burst) #
This is the most basic form of rate limiting, but it’s often too aggressive for real web applications.
server {
listen 443 ssl;
server_name example.com;
location /api/docs/ {
# Use the zone that was defined
limit_req zone=limit_per_ip;
proxy_pass http://backend;
}
}
- Behavior: If we set
rate=10r/son the zone, Nginx expects a 100ms gap between requests. If the user’s browser sends 2 requests at once (e.g., the main HTML request followed by a CSS file request 5ms later), the second request will be immediately rejected with a 503 status error by Nginx. The user’s browser will find many assets failing to load.
Scenario 2: Using a Buffer Queue (burst)
#
To prevent legitimate assets from being rejected due to browsers naturally sending parallel requests at the start of a page load, we must include the burst parameter:
location /api/ {
# Allow a deferral queue of up to 20 requests
limit_req zone=limit_per_ip burst=20;
proxy_pass http://api_backend;
}
- Behavior: If a user sends 15 requests instantly, the first request is immediately forwarded to the backend. The next 14 requests aren’t rejected, but are put into a queue (buffer bucket) with a capacity of 20 slots. Nginx then processes the 14 queued requests sequentially with a 100ms gap each.
- Drawback: Users will feel that their website loads very slowly (delay), because their requests are held in the queue and released slowly by Nginx.
Scenario 3: Instant Processing Without Delay (burst + nodelay)
#
For modern APIs or SPAs (Single Page Application), response delay is a bad user experience. The best solution is combining burst with the nodelay parameter:
location /api/v1/ {
# Process burst requests instantly, but still lock the slot quota
limit_req zone=limit_per_ip burst=20 nodelay;
proxy_pass http://api_backend;
}
- Behavior: If a user sends 15 requests instantly, all 15 requests are processed immediately without delay. However, the 14
burstqueue slots belonging to that IP are immediately marked as “used”. - This slot quota is gradually emptied according to the
rate=10r/space (1 slot frees up every 100ms). If that user sends additional requests before their burst quota slots are emptied, the new requests are immediately rejected with an error status.
Customizing the HTTP Response When the Limit Is Exceeded #
By default, Nginx returns HTTP 503 Service Unavailable when a user’s rate limit is exceeded. However, semantically per web standards, HTTP 503 indicates the server is generally overloaded.
To clearly distinguish this problem, we highly recommend changing the response code to HTTP 429 Too Many Requests. Modern clients (like the Axios HTTP library, Fetch, or bots) recognize code 429 and automatically perform retry pauses (exponential backoff).
We can set this customization using the limit_req_status and limit_req_log_level directives:
http {
limit_req_zone $binary_remote_addr zone=limit_per_ip:10m rate=10r/s;
# Change the error response code to 429
limit_req_status 429;
# Change the log level (default: error, recommended: warn)
limit_req_log_level warn;
}
Inserting a Custom Retry-After Header
#
Best API security practice is to specifically tell clients when they may try sending requests again via the Retry-After header:
server {
error_page 429 = @too_many_requests;
location @too_many_requests {
# Tell the client to wait 10 seconds
add_header Retry-After 10 always;
add_header Content-Type application/json;
return 429 '{"error": "Too many requests. Please try again in a moment.", "retry_after_seconds": 10}';
}
}
Real-World Case Study: Extremely Protecting the Login Page #
The login page is an easy target for dictionary (brute-force) attacks. We must strictly tighten access rates to the login endpoint separately, without disturbing users’ comfort when browsing general product pages.
http {
# Zone 1: General application rate limit (30 requests per second)
limit_req_zone $binary_remote_addr zone=app_global:10m rate=30r/s;
# Zone 2: Login-specific rate limit (3 requests per minute)
limit_req_zone $binary_remote_addr zone=login_strict:10m rate=3r/m;
}
server {
listen 443 ssl;
server_name example.com;
# Apply the general limit to the whole application
limit_req zone=app_global burst=50 nodelay;
# Apply the strict limit specifically to the login endpoint
location = /auth/login {
# Only allow 3 logins per minute, with a 2-request surge tolerance
limit_req zone=login_strict burst=2 nodelay;
limit_req_status 429;
proxy_pass http://auth_backend;
}
location / {
try_files $uri $uri/ =404;
}
}
Creating Rate Limit Exceptions (IP Whitelist) #
In production environments, there are sometimes special needs where we must exempt certain IP addresses from rate limiting rules. For example, internal office IPs, external monitoring servers (like Uptime Robot), or legitimate third-party API gateway IPs.
We can handle this elegantly using a combination of the geo and map modules:
http {
# Step 1: Classify IPs into the $is_whitelisted variable
geo $is_whitelisted {
default 1; # Default: subject to rate limit (value 1)
127.0.0.1 0; # Localhost exempt from limits (value 0)
10.0.0.0/8 0; # Internal VPN subnet exempt
203.0.113.80 0; # Office static public IP exempt
}
# Step 2: Map the geo result to the Nginx tracking key
map $is_whitelisted $limit_key {
0 ""; # If exempt, send an empty string
1 $binary_remote_addr; # If limited, use the IP as the key
}
# Step 3: Use the custom $limit_key variable on the zone
limit_req_zone $limit_key zone=dynamic_limit:10m rate=10r/s;
server {
location / {
limit_req zone=dynamic_limit burst=20 nodelay;
proxy_pass http://backend;
}
}
}
How Does This Whitelist Trick Work? #
Nginx has a very important internal rule: if the lookup key on the limit_req_zone directive is an empty string (""), Nginx doesn’t track that request and immediately lets it through without restriction.
- When a request comes from the office IP
203.0.113.80, thegeomodule reads that IP and matches it against the203.0.113.80 0rule. The$is_whitelistedvariable becomes0. - The
mapmodule then reads the0value of$is_whitelistedand maps it to the variable$limit_key = "". - Because
$limit_keyis empty, Nginx doesn’t add it to thedynamic_limitzone shared memory table and lets the request through without restriction. - Conversely, outside visitors (e.g., IP
180.250.2.1) will be mapped to the default value1bygeo, so$limit_keycontains the client IP in binary. Nginx processes the restriction normally.
Applying Multiple Zones at Once (Multiple Rate Limits) #
Nginx allows us to apply several limit_req directives simultaneously on one location scope. Incoming requests must pass through all the installed zone filters to be forwarded to the backend. If just one zone restricts the request, access is immediately rejected.
This technique is very useful for applying layered defense: strictly limiting request rates per individual IP, while collectively protecting the backend server by limiting the total request capacity the whole server block can handle.
http {
# Zone A: Strictly limit individual IPs (10 requests per second per IP)
limit_req_zone $binary_remote_addr zone=per_client:10m rate=10r/s;
# Zone B: Limit total requests to the whole server (500 requests per second for all users)
limit_req_zone $server_name zone=global_server:10m rate=500r/s;
}
server {
listen 443 ssl;
server_name api.example.com;
location / {
# Individual clients limited with burst 20
limit_req zone=per_client burst=20 nodelay;
# The server as a whole limited with burst 100
limit_req zone=global_server burst=100;
proxy_pass http://api_backend;
}
}
With the configuration above:
- A malicious user trying to send 50 requests/second on their own will immediately be stopped by the
per_clientfilter and blocked with status 429. - If there’s a massive legitimate traffic surge from thousands of different users simultaneously, each
per_clientfilter is safe. However, if the total requests exceed the server capacity (global_server500r/s + burst 100), the excess starts being rejected or delayed to save the backend database from system paralysis.
Analyzing and Monitoring Rate Limit Logs #
Doing rate limiting on a production server requires periodic monitoring. If our rules are too loose, the server remains at risk of being overwhelmed. Conversely, if too strict, legitimate users will often be disturbed by 429 status errors.
Understanding the Nginx Error Log Format #
When a request is blocked or delayed by rate limiting, Nginx writes an error message to the server’s error log file (error.log). An example block log line looks like this:
2026/06/16 13:00:00 [warn] 1234#0: *5678 limiting requests, excess: 10.050 by zone "limit_per_ip", client: 198.51.100.12, server: example.com, request: "POST /auth/login HTTP/2.0", host: "example.com"
limiting requests: Indicates the request was blocked or delayed.excess: The number of requests exceeding the set rate limit.zone: The name of the shared memory zone that triggered the block (limit_per_ip).client: The IP address of the attacker or user affected by the limit (198.51.100.12).request: The specific HTTP request being made.
Analyzing Logs via Command Line #
We can use standard Linux CLI utilities to analyze the log files and find out who gets blocked most often:
# 1. Find out which IP is most frequently rate limited
grep "limiting requests" /var/log/nginx/error.log | awk -F', client: ' '{print $2}' | awk '{print $1}' | sort | uniq -c | sort -nr | head -10
# 2. Find out which endpoint most frequently triggers rate limiting
grep "limiting requests" /var/log/nginx/error.log | awk -F', request: ' '{print $2}' | awk -F'"' '{print $1}' | sort | uniq -c | sort -nr | head -10
By monitoring these metrics periodically, we can adjust the rate and burst values in the Nginx configuration to align with our application’s real traffic patterns.
Summary #
- Use
$binary_remote_addr: Always use this binary-format variable as the tracking zone key to save shared memory consumption on the server.- Use
burst+nodelay: This combination is ideal for modern web applications because it speeds up page rendering without giving response delays (delay) that ruin the UX.- Change the Status Code to 429: Always include the
limit_req_status 429;directive so API clients can recognize and handle the request-overload problem with proper retries.- Apply Multi-Zone: Differentiate rate limit restrictions between light public endpoints (HTML/CSS) and sensitive database-hungry endpoints (like login pages or search).
- Leverage Geo + Map: Use the geo-map mapping pattern to cleanly exempt internal IPs or trusted monitoring agents from rate limit oversight.