Nginx Rate Limiting for WordPress Login and REST API Endpoints

Nginx’s limit_req module implements the leaky bucket algorithm for per-IP request rate limiting: incoming requests fill a memory-backed bucket at the actual request rate; the bucket empties at a defined constant rate; requests that arrive when the bucket is full are either delayed (nodelay absent) or immediately rejected with a 429 response (nodelay present). A rate limit zone is declared once in the http {} block with limit_req_zone $binary_remote_addr zone=name:size rate=N r/s$binary_remote_addr is the client IP as 4 bytes (IPv4) or 16 bytes (IPv6), so a 10 MB zone holds roughly 160,000 unique IPs for IPv4. The zone is applied per location with limit_req zone=name burst=B nodelay: burst=B allows up to B additional requests to queue before rejecting; nodelay processes burst requests immediately (passing them upstream without artificial delay) rather than spacing them at the zone rate. WordPress has three high-value targets for rate limiting: (1) wp-login.php — the login form is the primary brute-force vector; (2) xmlrpc.php — the XML-RPC API allows credential stuffing with a single HTTP request; (3) the REST API base path /wp-json/ — unauthenticated endpoints and authentication attempts at /wp-json/jwt-auth/v1/token are common abuse targets. For WordPress login specifically, limit_req_status 429 sets the HTTP status returned for rate-limited requests (default is 503 — 429 “Too Many Requests” is semantically correct and better handled by monitoring tools and CDNs). Whitelisting office IP ranges with geo $limited { default 1; 203.0.113.0/24 0; } combined with limit_req zone=login if ($limited) allows staff logins without rate-limit friction. The UFW/fail2ban post covered OS-level IP blocking after failed auth attempts; Nginx rate limiting operates at the web-server layer and blocks the request before it reaches PHP-FPM or WordPress, eliminating PHP execution overhead for flood traffic.

Problem: Server access logs show 8,000+ requests per minute to wp-login.php from rotating IPs during a credential-stuffing attack. PHP-FPM queue fills up, legitimate traffic times out, and fail2ban only bans each IP after 5 hits — meaning 5 login attempts slip through per IP before a ban triggers.

Solution: Define Nginx limit_req_zone rate zones for login, XML-RPC, and REST API separately, apply strict limit_req directives in the corresponding location blocks, and serve a static 429 page for blocked requests to avoid any PHP execution overhead under flood load.

# /etc/nginx/nginx.conf — http {} block
# Define rate-limit zones (declared once, used in any server/location block)

# Login: max 1 request/10 sec per IP (6/min) with a burst of 3
limit_req_zone $binary_remote_addr zone=wp_login:10m  rate=1r/10s;

# XML-RPC: max 1 request/30 sec per IP — most legitimate uses are batched
limit_req_zone $binary_remote_addr zone=wp_xmlrpc:5m  rate=1r/30s;

# REST API: max 30 requests/min per IP (comfortable for legitimate JS clients)
limit_req_zone $binary_remote_addr zone=wp_rest:20m   rate=30r/m;

# Return 429 instead of 503 for rate-limited requests
limit_req_status 429;

# Optional: whitelist trusted IP ranges (office, monitoring, staging)
geo $rate_limit_bypass {
    default         1;       # apply limits
    127.0.0.1       0;       # localhost — never limit
    10.0.0.0/8      0;       # private network
    203.0.113.0/24  0;       # example office range — replace with real CIDR
}

# /etc/nginx/sites-available/helloadmin.conf — server {} block

server {
    listen 443 ssl http2;
    server_name helloadmin.com www.helloadmin.com;

    root /var/www/helloadmin;
    index index.php;

    # ── wp-login.php: strict login rate limit ─────────────────────────────
    location = /wp-login.php {
        # If IP is in the bypass geo list, skip the zone limit
        limit_req zone=wp_login burst=3 nodelay if ($rate_limit_bypass);

        # Serve a static 429 page to avoid PHP execution during floods
        limit_req_log_level warn;
        error_page 429 /rate-limit.html;

        # Only allow POST (login form submit) and GET (form display)
        limit_except GET POST { deny all; }

        include fastcgi_params;
        fastcgi_pass unix:/run/php/php8.2-fpm.sock;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    }

    # ── xmlrpc.php: block by default, rate-limit the few legitimate uses ──
    location = /xmlrpc.php {
        # To block XML-RPC entirely (recommended if not used):
        # return 403;

        # To allow limited XML-RPC (e.g., Jetpack, mobile apps):
        limit_req zone=wp_xmlrpc burst=2 nodelay;
        error_page 429 /rate-limit.html;

        include fastcgi_params;
        fastcgi_pass unix:/run/php/php8.2-fpm.sock;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    }

    # ── REST API: moderate rate limit ────────────────────────────────────
    location ^~ /wp-json/ {
        limit_req zone=wp_rest burst=15 nodelay;
        error_page 429 /rate-limit.html;

        # Rewrite to index.php (WordPress pretty permalinks)
        try_files $uri $uri/ /index.php?$args;

        include fastcgi_params;
        fastcgi_pass unix:/run/php/php8.2-fpm.sock;
        fastcgi_param SCRIPT_FILENAME $document_root/index.php;
    }

    # ── Static 429 page — served without touching PHP ─────────────────
    location = /rate-limit.html {
        internal;
        root /var/www/error-pages;
    }

    # ── Everything else ───────────────────────────────────────────────
    location / {
        try_files $uri $uri/ /index.php?$args;
    }

    location ~ \.php$ {
        include fastcgi_params;
        fastcgi_pass unix:/run/php/php8.2-fpm.sock;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    }
}

# Test the configuration and reload Nginx
sudo nginx -t && sudo systemctl reload nginx

# Verify rate limiting is active by checking the error log after a burst test
sudo tail -f /var/log/nginx/error.log | grep limiting

# Watch live 429 counts per minute
sudo awk '$9==429' /var/log/nginx/access.log | wc -l

# Check current rate-limit zone memory usage (Nginx Plus / stub_status)
curl -s http://127.0.0.1/nginx_status

NOTE: limit_req if ($variable) is not supported by Nginx’s limit_req directive — the if parameter shown for the geo bypass is a conceptual simplification. The correct way to conditionally apply rate limiting is to set $limit_key via a map {} block: map $rate_limit_bypass to $limit_key so that whitelisted IPs map to an empty string (an empty key skips limit_req_zone accounting), then define the zone as limit_req_zone $limit_key zone=wp_login:10m rate=1r/10s. Also, if your site sits behind Cloudflare or another reverse proxy, $binary_remote_addr will be the proxy’s IP — all traffic will share one bucket and legitimate users will be blocked immediately. In that case, use $http_cf_connecting_ip (set by Cloudflare) as the zone key after verifying the request actually came through Cloudflare using the ngx_http_realip_module with set_real_ip_from for Cloudflare’s published IP ranges.