Nginx Rate Limiting: Protecting Your API and Login Pages from Abuse

Brute-force login attempts, credential stuffing, and API scraping are routine threats for any public-facing server. Nginx's built-in rate limiting module stops these attacks at the edge — before PHP even starts. This guide covers the full directive set, burst configuration, exempting trusted IPs, protecting Drupal's login and JSON:API endpoints, and avoiding the most common misconfiguration that lets bursts overwhelm your server.

The Leaky Bucket Model

Nginx rate limiting uses the "leaky bucket" algorithm: requests fill a bucket at the client's rate; the bucket drains at a fixed rate you define. Requests that arrive when the bucket is full are either delayed or rejected. The burst parameter controls how many excess requests can be held in the bucket before Nginx starts returning 429s.

Core Directives

limit_req_zone

Defined in the http context. Creates a named shared memory zone that tracks state per key (typically client IP):

http {
    # 10m zone holds ~160,000 IP states (64 bytes per state)
    limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;
    limit_req_zone $binary_remote_addr zone=api:10m   rate=30r/s;
    limit_req_zone $binary_remote_addr zone=global:20m rate=100r/s;
}
  • $binary_remote_addr — 4 bytes for IPv4, 16 bytes for IPv6; more compact than $remote_addr
  • zone=name:size — shared memory zone name and size (10m = 10 MB)
  • rate — accepts r/s (per second) or r/m (per minute)

limit_req

Applied in server, location, or if context:

location /user/login {
    limit_req zone=login burst=3 nodelay;
    # ... fastcgi config
}
  • burst=N — allow N requests to queue above the rate limit
  • nodelay — serve burst requests immediately (no artificial delay); reject when burst is full
  • delay=N — serve first N burst requests immediately, delay the rest until capacity frees

burst vs nodelay vs delay: The Critical Difference

This is where most configurations go wrong. Consider rate=1r/s burst=5:

ConfigurationBehaviour on 10 simultaneous requests
burst=5 (no modifier)First request served now; next 5 queued and served 1/second; last 4 rejected 503
burst=5 nodelayFirst 6 requests served immediately; last 4 rejected 503. Token bucket still drains at 1/s
burst=5 delay=2First 3 served immediately; next 3 queued; last 4 rejected 503

For login endpoints: use burst=3 nodelay — a human can type quickly but not 10 times per second.

For APIs: use burst=20 nodelay — allows legitimate bursty clients while capping sustained abuse.

Complete Drupal Configuration

http {
    # ── Rate Limit Zones ─────────────────────────────────────────────────────

    # Login: 5 attempts per minute per IP
    limit_req_zone $binary_remote_addr zone=drupal_login:10m rate=5r/m;

    # JSON:API / REST: 60 requests per second per IP (generous for APIs)
    limit_req_zone $binary_remote_addr zone=drupal_api:10m rate=60r/s;

    # General PHP: catch-all for anonymous traffic
    limit_req_zone $binary_remote_addr zone=drupal_php:20m rate=20r/s;

    # Per-server (not per-IP) to prevent server-wide floods
    limit_req_zone $server_name zone=drupal_server:10m rate=500r/s;

    # ── Status and Log Level ─────────────────────────────────────────────────
    limit_req_status 429;
    limit_req_log_level warn;

    # ── Trusted IP Exemptions ─────────────────────────────────────────────────
    # geo and map must be in the http context, not inside server {}
    geo $limit {
        default         1;   # Apply rate limiting by default
        127.0.0.1       0;   # Localhost
        10.0.0.0/8      0;   # Internal network
        192.168.1.50    0;   # Office IP
    }

    # Map to empty string (no zone) for exempt IPs
    map $limit $limit_key {
        0   "";
        1   $binary_remote_addr;
    }

    server {
        listen 443 ssl;
        server_name example.com;
        root /var/www/html/web;

        # ── Login Page ────────────────────────────────────────────────────────
        location = /user/login {
            limit_req zone=drupal_login burst=3 nodelay;
            limit_req zone=drupal_server burst=50 nodelay;

            # Return friendly error page on rate limit
            error_page 429 /429.html;

            try_files $uri /index.php?$query_string;
            include fastcgi_params;
            fastcgi_pass php:9000;
            fastcgi_param SCRIPT_FILENAME $document_root/index.php;
        }

        # Password reset — even stricter
        location = /user/password {
            limit_req zone=drupal_login burst=1 nodelay;
            try_files $uri /index.php?$query_string;
            include fastcgi_params;
            fastcgi_pass php:9000;
            fastcgi_param SCRIPT_FILENAME $document_root/index.php;
        }

        # ── JSON:API ──────────────────────────────────────────────────────────
        location ^~ /jsonapi/ {
            limit_req zone=drupal_api burst=30 nodelay;
            limit_req zone=drupal_server burst=200 nodelay;

            try_files $uri /index.php?$query_string;
            include fastcgi_params;
            fastcgi_pass php:9000;
            fastcgi_param SCRIPT_FILENAME $document_root/index.php;
        }

        # ── General PHP ───────────────────────────────────────────────────────
        location ~ \.php$ {
            limit_req zone=drupal_php burst=10 nodelay;

            try_files $uri =404;
            include fastcgi_params;
            fastcgi_pass php:9000;
            fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        }

        # Static assets — no rate limiting needed
        location ~* \.(css|js|jpg|jpeg|png|gif|webp|avif|woff2|svg|ico)$ {
            expires 1y;
            add_header Cache-Control "public, immutable";
        }

        # 429 error page
        location = /429.html {
            internal;
            return 429 '{"error":"Too many requests. Please slow down."}';
            add_header Content-Type application/json;
        }
    }
}

Using geo and map for Per-IP Exemptions

The geo approach above is the cleanest way to exempt IPs. An alternative using map with a variable key:

http {
    # Set limit zone key to empty string for whitelisted IPs
    geo $exempt_ip {
        default           0;
        127.0.0.1         1;
        10.0.0.0/8        1;
    }

    map $exempt_ip $rate_limit_key {
        1   "";                     # Empty = no zone lookup = no limiting
        0   $binary_remote_addr;
    }

    limit_req_zone $rate_limit_key zone=login:10m rate=5r/m;
}

When $rate_limit_key is an empty string, Nginx skips the rate limit check entirely for that request.

Protecting xmlrpc.php and Other Attack Vectors

# Block common WordPress/Drupal attack endpoints outright
location = /xmlrpc.php {
    return 444;   # Close connection with no response
}

# Aggressive rate limit on user registration (if enabled)
location = /user/register {
    limit_req zone=drupal_login burst=2 nodelay;
    try_files $uri /index.php?$query_string;
    include fastcgi_params;
    fastcgi_pass php:9000;
    fastcgi_param SCRIPT_FILENAME $document_root/index.php;
}

# Rate limit search (expensive Drupal operation)
location = /search/node {
    limit_req zone=drupal_php burst=2 nodelay;
    try_files $uri /index.php?$query_string;
    include fastcgi_params;
    fastcgi_pass php:9000;
    fastcgi_param SCRIPT_FILENAME $document_root/index.php;
}

Dry Run Mode: Test Before Enforcing

Nginx 1.17.1+ supports limit_req_dry_run: log rejections without actually blocking requests. Use this to tune limits on production traffic:

location /user/login {
    limit_req zone=drupal_login burst=3 nodelay;
    limit_req_dry_run on;   # Log but don't actually reject
}

Watch the log:

tail -f /var/log/nginx/error.log | grep "limiting requests"
# You'll see: [warn] limiting requests, excess: 0.800 by zone "drupal_login"

Testing Rate Limits

# Send 20 requests in rapid succession and count 429 responses
for i in $(seq 1 20); do
  curl -s -o /dev/null -w "%{http_code}\n" https://example.com/user/login
done

# Apache Bench — 50 requests, 10 concurrent
ab -n 50 -c 10 https://example.com/user/login

# wrk — 10 seconds, 10 threads, 100 connections
wrk -t10 -c100 -d10s https://example.com/jsonapi/node/article

Monitoring Rate Limit Events

# Count 429s in access log
awk '$9 == 429' /var/log/nginx/access.log | wc -l

# Top IPs being rate limited
awk '$9 == 429 {print $1}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head -20

# Count rate limit log entries
grep "limiting requests" /var/log/nginx/error.log | wc -l

Summary Checklist

  • Define zones in the http block with limit_req_zone; apply with limit_req in specific locations
  • Use $binary_remote_addr not $remote_addr — saves 12 bytes per entry in the zone
  • Understand nodelay: burst requests are served immediately but tokens are still consumed — it does not raise your effective rate
  • Set limit_req_status 429 (not the default 503) so clients can distinguish rate limiting from server errors
  • Use geo + map to exempt internal IPs and monitoring tools from rate limits
  • Apply strict limits on /user/login, /user/password, /user/register — 5r/m is appropriate for login
  • Add a per-server zone in addition to per-IP zones to cap aggregate traffic from distributed attacks
  • Test with limit_req_dry_run on before enforcing; watch error logs for excess values
  • Set fastcgi_read_timeout longer than your rate limit window so queued requests don't timeout
  • Monitor 429s in access logs weekly; adjust burst values based on real traffic patterns
Tags

Add new comment

Restricted HTML

  • Allowed HTML tags: <a href hreflang> <em> <strong> <cite> <blockquote cite> <code> <ul type> <ol start type> <li> <dl> <dt> <dd> <h2 id> <h3 id> <h4 id> <h5 id> <h6 id>
  • Lines and paragraphs break automatically.
  • Web page addresses and email addresses turn into links automatically.
Please share this article on your favorite website or platform.