Nginx Rate Limiting and Abuse Protection for Public APIs

devops-nginx

The Problem

Nginx rate limiting and abuse protection illustration

Nginx rate limiting and abuse protection for public APIs is one of those topics that engineers tend to defer until something goes wrong — a scraper hammers a product catalog endpoint, a credential-stuffing campaign saturates the auth service, or a misconfigured client floods the API with retry loops. By the time the incident is in progress, the options narrow quickly: block at the firewall and lose legitimate traffic in the crossfire, or scramble to push an untested Nginx config under pressure.

The underlying issue is that HTTP APIs are stateless and cheap to call. Without explicit controls at the ingress layer, every endpoint is effectively open to unbounded request rates. A single IP can exhaust upstream connection pools, inflate your cloud egress bill, and degrade latency for real users — all without triggering anything that looks like a traditional attack in your monitoring. Rate limiting at the Nginx level addresses this before requests ever reach application code, which means lower cost, faster rejection, and no additional load on your backend services.

This tutorial focuses on a production-grade Nginx configuration: leaky bucket zones for general traffic, tighter zones for sensitive endpoints, connection caps, bot pre-screening, and machine-readable error responses. The goal is a configuration you can deploy and reason about — not a theoretical overview.

Approach

Nginx implements two conceptually distinct rate limiting mechanisms, and understanding which one to reach for matters.

Leaky bucket (via ngx_http_limit_req_module) controls the rate of incoming requests per key — typically per client IP. Requests that arrive faster than the configured rate are either delayed, burst-queued, or rejected. The rate=10r/s parameter defines the drain rate of the bucket. This is the right tool for per-IP request throttling across API endpoints.

Connection limiting (via ngx_http_limit_conn_module) caps the number of simultaneous open connections per key. This complements request-rate limiting by preventing slow-read attacks and connection exhaustion from clients that open many persistent connections without necessarily sending many requests.

Both modules use shared memory zones defined in the http {} block — a common misconfiguration is placing limit_req_zone inside a server {} block, which causes Nginx to fail on reload. The zone key $binary_remote_addr is preferable to $remote_addr because it stores IPv4 addresses in 4 bytes and IPv6 in 8 bytes, compared to the 7–15 bytes of the string representation. For a 10 MB zone, that difference translates to significantly more tracked clients before the zone fills and Nginx starts rejecting all requests.

The pattern we’re building here layers these mechanisms: broad rate zones for general API traffic, stricter zones for authentication endpoints, connection caps across all locations, and a bot pre-screening map that short-circuits the entire pipeline for known bad user agents.

Defining Rate Limit Zones

Zone definitions belong in the http {} context — either directly in /etc/nginx/nginx.conf or in a dedicated file under /etc/nginx/conf.d/ that is included before your server blocks. Splitting them into a file like api-rate-limit.conf keeps the configuration readable and makes it easier to audit limits independently of routing logic.

We define two request zones with different rates: a general zone at 10 requests per second for standard API endpoints, and a stricter auth zone at 3 requests per second for login and token endpoints. A single connection zone covers both locations.

# /etc/nginx/conf.d/api-rate-limit.conf

# --- Zone definitions (must live in http context) ---
limit_req_zone  $binary_remote_addr  zone=api_general:10m  rate=10r/s;
limit_req_zone  $binary_remote_addr  zone=api_auth:10m     rate=3r/s;
limit_conn_zone $binary_remote_addr  zone=api_conn:10m;

# --- Block known bad user agents ---
map $http_user_agent $is_bad_bot {
    default         0;
    "~*scrapy"      1;
    "~*python-requests/2.2" 1;
    ""              1;
}

The 10m zone size is sufficient for approximately 160,000 concurrent tracked IPs with $binary_remote_addr. For APIs with global reach and high unique-IP volume, consider increasing this to 20m or 50m. When a zone fills completely, Nginx rejects all requests matching that zone — which is a hard failure, not graceful degradation. Size your zones with headroom.

The map block for bad bots runs before any rate limit zone is evaluated. Scrapers using Scrapy, bare python-requests versions known to appear in scraping toolchains, and requests with an empty User-Agent header are flagged here. This is intentionally conservative — you can extend it with additional patterns as you observe them in your access logs.

Applying Limits to API Locations

With zones defined, the server block wires them to specific location contexts. The burst parameter and nodelay flag deserve careful attention here — they determine whether legitimate traffic with natural burstiness gets queued or immediately rejected.

server {
    listen 443 ssl http2;
    server_name api.example.com;

    # Reject bad bots before rate limit evaluation
    if ($is_bad_bot) {
        return 403 '{"error":"forbidden"}';
    }

    # Custom error pages for API consumers
    error_page 429 /errors/429.json;
    error_page 503 /errors/503.json;

    location /errors/ {
        internal;
        root /var/www/api;
        add_header Content-Type application/json always;
    }

    # General API endpoints
    location /api/v1/ {
        limit_req  zone=api_general burst=20 nodelay;
        limit_conn zone=api_conn 15;
        limit_req_status 429;
        limit_conn_status 429;

        add_header Retry-After 1 always;
        add_header X-RateLimit-Limit 10 always;

        proxy_pass http://backend_upstream;
        proxy_set_header X-Real-IP $remote_addr;
    }

    # Stricter limits on auth endpoints
    location /api/v1/auth/ {
        limit_req  zone=api_auth burst=5 nodelay;
        limit_conn zone=api_conn 5;
        limit_req_status 429;
        limit_conn_status 429;

        add_header Retry-After 5 always;

        proxy_pass http://backend_upstream;
        proxy_set_header X-Real-IP $remote_addr;
    }
}

A few decisions worth explaining explicitly. The nodelay flag means burst requests are processed immediately rather than queued — without it, a burst of 20 requests would be spread out over 2 seconds of artificial delay, which is worse for latency than a clean 429. For an API where clients are expected to handle rate limit responses and retry, nodelay is almost always the right choice.

The limit_req_status 429 and limit_conn_status 429 directives override Nginx’s default behavior of returning 503 for rate-limited requests. HTTP 503 implies a server-side problem; HTTP 429 correctly signals that the client is sending too many requests. Any well-behaved API client will handle these differently, and your monitoring should too.

The auth location gets a connection cap of 5 rather than 15. Authentication endpoints are a common target for credential stuffing, where the attacker’s goal is throughput, not latency tolerance. A tight connection cap forces sequential attempts and makes large-scale stuffing campaigns significantly more expensive.

Hardening with Connection and Geo Limits

The configuration above handles the common case. For APIs that need tighter controls — particularly those exposed to the public internet without an upstream WAF — additional layers are worth adding.

If your API has a known set of legitimate clients (internal services, partner integrations, mobile apps behind a fixed egress IP), a geo-based allowlist can bypass rate limits for trusted sources while keeping strict limits for anonymous traffic. The ngx_http_geo_module handles this cleanly:

# In http {} context
geo $trusted_client {
    default         0;
    10.0.0.0/8      1;   # Internal network
    203.0.113.42    1;   # Partner egress IP
}

# In location block
location /api/v1/internal/ {
    if ($trusted_client = 0) {
        return 403 '{"error":"access denied"}';
    }
    proxy_pass http://backend_upstream;
}

For log verbosity, limit_req_log_level controls how Nginx logs rejected requests. The default is error, but in environments where rate limiting is frequent and expected, this floods your error log. Setting it to warn or info keeps the signal-to-noise ratio manageable:

limit_req_log_level warn;
limit_conn_log_level warn;

These directives can be set at the http, server, or location level. Setting them at the http level applies the behavior globally, which is typically what you want when rate limiting is deployed across multiple API server blocks. You can find the full directive reference in the official Nginx limit_req module documentation.

Returning Proper Error Responses

A rate-limited API that returns an HTML error page or a plain-text 429 is functionally broken for API consumers. Client SDKs, monitoring systems, and retry logic all depend on structured, machine-readable responses. The error_page directive combined with a static JSON file solves this without adding application complexity.

Create the error response files on disk at the path your root directive points to:

# /var/www/api/errors/429.json
{
  "error": "rate_limit_exceeded",
  "message": "Too many requests. Please slow down and retry after the indicated delay.",
  "status": 429
}

# /var/www/api/errors/503.json
{
  "error": "service_unavailable",
  "message": "The service is temporarily unavailable. Please retry shortly.",
  "status": 503
}

The add_header Retry-After directive in each location block tells clients how long to wait before retrying. For general API traffic at 10r/s, a value of 1 is appropriate. For the auth endpoint at 3r/s, 5 gives enough back-pressure to discourage rapid retries without being so long that legitimate users are frustrated by a single burst.

Note the always flag on add_header directives — without it, Nginx only adds the header on 2xx and 3xx responses. Since 429 is a 4xx status, the header would be silently dropped without always. This is a subtle but consequential omission that breaks client retry logic.

For more on structuring API-layer infrastructure on AWS, the DevOps_DayS blog covers related patterns including EventBridge retry policies and Lambda dead letter queue handling that complement ingress-level rate limiting in a layered defense architecture.

Verify It Works

Configuration that hasn’t been tested under load is a hypothesis. Before deploying to production, verify that limits trigger at the expected thresholds and that error responses are correctly formatted.

The hey tool is straightforward for this. Send 200 requests with a concurrency of 20 against the general API endpoint and inspect both the response code distribution and the Nginx access log:

hey -n 200 -c 20 https://api.example.com/api/v1/resource

You should see a mix of 200 and 429 responses. The exact ratio depends on backend latency and how quickly the burst queue drains, but with rate=10r/s and burst=20 nodelay, a 20-concurrency burst will exhaust the burst allowance almost immediately and begin returning 429s for the remainder of the run.

Confirm the 429 response body is JSON and the headers are present:

curl -si https://api.example.com/api/v1/resource | head -30

Look for HTTP/2 429, Retry-After: 1, X-RateLimit-Limit: 10, and a Content-Type: application/json header. If Content-Type is missing or shows text/html, the add_header Content-Type application/json always; in the /errors/ location is not being applied — double-check the internal flag and root path.

Inspect the access log to confirm Nginx is logging rate-limited requests at the correct level:

tail -f /var/log/nginx/access.log | grep " 429 "

For the auth endpoint, test with a lower concurrency to verify the tighter zone triggers correctly:

hey -n 50 -c 10 https://api.example.com/api/v1/auth/login

With rate=3r/s and burst=5 nodelay, 429s should appear after the first 5 requests in the burst window. If they don’t, verify the location block order — Nginx uses the most specific matching location, so confirm /api/v1/auth/ is not being captured by the broader /api/v1/ block instead.

Related Ideas

The Nginx configuration described here is effective as a standalone layer, but it operates without memory between requests — once a rate-limited IP backs off and waits, it can start a new burst cycle. For persistent abusers, fail2ban can watch the Nginx access log for repeated 429s and issue dynamic iptables or nftables blocks. A jail configured to trigger on 50 rate-limit rejections within 60 seconds from a single IP adds a stateful enforcement layer that Nginx alone cannot provide.

At the infrastructure edge, Cloudflare WAF rate limiting rules operate before traffic reaches your origin, which means the abuse never consumes your server’s CPU or bandwidth. Cloudflare’s rate limiting works on a per-URL-pattern basis with fingerprinting options beyond IP — useful for APIs where clients share egress IPs through NAT or proxies. The Cloudflare WAF rate limiting documentation covers the rule syntax and threshold configuration.

For APIs that require authenticated rate limiting — where limits are per API key or per user account rather than per IP — the right layer is an API gateway: AWS API Gateway with usage plans, Kong with the rate-limiting plugin, or a custom auth middleware that injects rate limit decisions upstream of Nginx. IP-based limiting remains valuable as a baseline, but authenticated limits allow you to give trusted clients higher quotas while still protecting against unauthenticated abuse.

Finally, if you’re running this configuration on Kubernetes, the same Nginx directives apply when using the ingress-nginx controller’s rate limiting annotations — though the annotation interface abstracts some of the zone configuration, understanding the underlying directives makes debugging annotation behavior significantly easier.

Leave a Reply

Your email address will not be published. Required fields are marked *

Support us · 💳 Monobank