Technology

The Calm Way to Stop wp-login.php and XML‑RPC Brute Force: Nginx Rate Limiting + Fail2ban

So there I was, coffee in hand, reading through yet another noisy access log. Lines and lines of POST /wp-login.php requests, all from random IPs that had never bought a thing, never read a blog post, never even loaded the homepage. If you’ve ever managed a WordPress site for more than a week, I bet you’ve seen this too. Ever had that moment when your CPU spikes for no good reason, or your admin panel feels oddly sluggish despite low traffic? Nine times out of ten, it’s the constant drumbeat of bots pecking at wp-login.php and xmlrpc.php.

Here’s the thing: you don’t need to play whack‑a‑mole with plugins or hold your breath hoping the storm passes. You can get ahead of it—calmly and predictably—with a simple, layered approach I’ve used across client sites and my own projects. In this friendly guide, we’ll walk through a practical setup using Nginx rate limiting to slow the bots down and Fail2ban to show chronic offenders the door. We’ll talk about what’s actually happening, how to tune the limits so your real users don’t feel punished, how to avoid common pitfalls (hello, Cloudflare), and how to keep your logs clean so you can sleep at night.

By the end, you’ll have a working recipe: a gate that politely slows excessive requests and a bouncer that bans the troublemakers—no drama needed.

Why wp-login.php and XML-RPC get hammered (and what it feels like)

I remember a client who swore they were being “DDoS’d” because their WordPress admin was sluggish every afternoon. Turned out it wasn’t a volumetric attack—it was a slow, persistent drip of login attempts and XML-RPC probes from hundreds of IPs. Not huge traffic, but just enough noise to keep PHP busy, disks chattering, and everyone grumpy. That’s the sneaky part: these bots don’t always try to break your door; sometimes they just lean on it all day, hoping a hinge gives.

Two targets steal the show. First, wp-login.php, the classic login endpoint. Bots try common passwords and leaked email/user combos. It’s credential stuffing more than guesswork. Second, xmlrpc.php, which allows remote procedure calls. It’s useful for some tooling (Jetpack, the WordPress mobile app), but it’s also been abused for brute force via system.multicall, letting attackers attempt many logins in a single request. Even if your credentials are strong, the resource cost of handling those requests adds up.

What you’ll notice: odd CPU rises that don’t map to legitimate traffic, admin login feels sticky, and logs full of repetitive POSTs. Sometimes WooCommerce checkout slows down for no obvious reason. The app isn’t “broken”—it’s just busy serving freeloaders.

The simple, layered plan: slow at the gate, ban at the door

In my experience, the cleanest defense uses two layers that complement each other. Think of Nginx rate limiting as the gentle gate that lets normal behavior through and slows pushy requests. It doesn’t get angry; it just says, “Whoa there, one at a time.” Then Fail2ban watches for persistent misbehavior (like getting hit with 429 Too Many Requests or 403 Forbidden repeatedly) and adds those IPs to a ban list. That’s your bouncer: firm, fair, and not emotional.

Why both? Rate limiting alone reduces load but persistent bad actors keep leaning. Fail2ban alone is reactive and can miss bursts that overwhelm PHP before the ban kicks in. Together, they’re fantastic. Nginx cuts the majority of the noise cheaply at the edge; Fail2ban removes the repeat offenders from the equation entirely.

We’ll build this in steps: make sure Nginx sees the real client IP (super important behind a CDN), add a sensible log format, apply targeted rate limits to wp-login.php and xmlrpc.php, and then teach Fail2ban to ban IPs that keep misbehaving. Along the way, we’ll talk about whitelisting your office IP, avoiding false positives, and what to do if you need XML-RPC for specific services.

Preparing Nginx: real IPs, clean logs, and a calm playground

Step 1: Make sure Nginx sees the visitor’s real IP

If you’re behind a CDN or reverse proxy (Cloudflare, a load balancer, another Nginx), Nginx might only see the proxy’s IP. If you rate limit or ban that, you’ll block everyone. That’s… not ideal.

Set the real IP headers before anything else. For Cloudflare, for example, you’d do:

http {
    # If you're on Cloudflare
    set_real_ip_from 173.245.48.0/20;
    set_real_ip_from 103.21.244.0/22;
    set_real_ip_from 103.22.200.0/22;
    set_real_ip_from 103.31.4.0/22;
    set_real_ip_from 141.101.64.0/18;
    set_real_ip_from 108.162.192.0/18;
    set_real_ip_from 190.93.240.0/20;
    set_real_ip_from 188.114.96.0/20;
    set_real_ip_from 197.234.240.0/22;
    set_real_ip_from 198.41.128.0/17;
    set_real_ip_from 162.158.0.0/15;
    set_real_ip_from 104.16.0.0/13;
    set_real_ip_from 104.24.0.0/14;
    set_real_ip_from 172.64.0.0/13;
    set_real_ip_from 131.0.72.0/22;

    real_ip_header CF-Connecting-IP;
    real_ip_recursive on;
}

Cloudflare updates these ranges periodically, so keep them fresh or use their published list. If you’re on a different proxy, use its equivalent. This matters for both rate limiting and Fail2ban.

If you’re curious about the correct header and setup for Cloudflare, their restoring original visitor IPs guide is a handy reference.

Step 2: Add a log format that’s easy for Fail2ban

I like a straightforward log line that includes the status and request line. Here’s a minimal example you can drop into the http block:

http {
    log_format main_ext '$remote_addr - $remote_user [$time_local] '
                        '"$request" $status $body_bytes_sent '
                        '"$http_referer" "$http_user_agent" '
                        'rt=$request_time';

    access_log /var/log/nginx/access.log main_ext;
}

We’ll lean on this later when Fail2ban looks for repeated 429s or 403s for the login endpoints.

Step 3: Define whitelist/allowlist for your own IPs (optional but nice)

You don’t have to do this, but it’s humane. If there’s a static office IP or a secure VPN range, allow it to skip limits. The map directive makes this pleasant:

http {
    map $remote_addr $wp_skip_limit {
        default 0;
        203.0.113.10 1;     # Example: Office IP
        2001:db8::/48 1;     # Example: Office IPv6 prefix
    }
}

We’ll use $wp_skip_limit inside the locations to skip rate limiting when it equals 1.

Nginx rate limiting: firm, friendly, and focused

You don’t need to rate limit your entire site—just the choke points. The idea is to target POST /wp-login.php and POST /xmlrpc.php and keep the limits humane. For wp-login.php, you want it tight. For xmlrpc.php, you want it tighter, especially against system.multicall.

Step 4: Define zones and rates

In the http block, add two zones. Memory size roughly maps to how many distinct IPs you expect to track; 10m is plenty for most sites.

http {
    # ...real IP, log_format, map from above...

    # One token bucket per client IP for login and XML-RPC
    limit_req_zone $binary_remote_addr zone=login_zone:10m rate=3r/m;
    limit_req_zone $binary_remote_addr zone=xmlrpc_zone:10m rate=10r/m;
}

Translation: allow around 3 login attempts per minute per IP, and about 10 XML-RPC requests per minute. Adjust later if you have special tooling. The point is to keep the pressure off PHP.

Step 5: Apply limits to the right places

Inside your server block for the site:

server {
    # ... SSL, server_name, root, etc. ...

    # 429s for limits, don’t burst too high
    limit_req_status 429;

    location = /wp-login.php {
        # Skip if allowlisted
        if ($wp_skip_limit) {
            set $limit_bypass 1;
        }

        # Only limit POST requests; GET for rendering login page is less risky
        if ($request_method = POST) {
            set $is_login_post 1;
        }

        # Apply limit only when needed
        if ($is_login_post = 1) {
            limit_req zone=login_zone burst=2 nodelay;
        }

        include fastcgi_params;
        # ...your fastcgi_pass to PHP-FPM here...
    }

    location = /xmlrpc.php {
        if ($wp_skip_limit) { set $limit_bypass 1; }
        if ($request_method = POST) { set $is_xmlrpc_post 1; }

        # Stricter on XML-RPC POSTs
        if ($is_xmlrpc_post = 1) {
            limit_req zone=xmlrpc_zone burst=5 nodelay;
        }

        # Optional: block the notorious pingback.ping without breaking all XML-RPC
        # Quick-and-dirty content sniff (lightweight but not perfect):
        # if ($request_body ~* "pingback.ping") { return 403; }

        include fastcgi_params;
        # ...your fastcgi_pass to PHP-FPM here...
    }
}

I tend to avoid heavy request body inspections in Nginx for performance, but a small match like pingback.ping can be surprisingly effective. If you don’t use XML-RPC at all, the simplest move is to deny it entirely:

location = /xmlrpc.php { return 403; }

Just be sure you’re not using Jetpack, mobile app publishing, or anything else that depends on it before flipping that switch.

Why nodelay?

Using nodelay means Nginx won’t queue requests—it will reject extra ones immediately with 429 instead of slowly buffering them. In brute force land, immediate rejection is kinder to your server. Tools and humans can always try again later.

If you want to go deeper into the knobs and dials, the official Nginx rate limiting docs give you the full story.

Fail2ban: the patient bouncer who remembers faces

Nginx has drawn the line in the sand. Now we’ll ask Fail2ban to watch the logs for repeated abuse. If an IP keeps hitting rate limits (429) or forbidden zones (403), Fail2ban will add a firewall rule to drop traffic from that IP for a while. It’s a time‑out for bots.

Step 6: Install Fail2ban and set the basics

On most Linux distros:

# Debian/Ubuntu
sudo apt-get update && sudo apt-get install -y fail2ban

# RHEL/CentOS/Rocky/Alma
sudo dnf install -y fail2ban

# Start and enable
sudo systemctl enable --now fail2ban

If you’re using nftables (which I like for modern setups), Fail2ban has nft actions built in. If you prefer iptables-legacy or are on a host with a managed firewall, adapt accordingly.

Step 7: Create dedicated jails for wp-login.php and xmlrpc.php

We’ll add two jails, one for each endpoint. They’ll watch the Nginx access log, flag repeated errors, and ban the IP. Create a file at /etc/fail2ban/jail.d/wordpress-bruteforce.conf:

[nginx-wp-login]
enabled = true
port    = http,https
filter  = nginx-wp-login
logpath = /var/log/nginx/access.log
maxretry = 5
findtime = 10m
bantime = 1h
action  = %(action_mwl)s
# Suggestions: use nftables if available
# action = nftables-multiport[name=wp-login, port="http,https"]

[nginx-xmlrpc]
enabled = true
port    = http,https
filter  = nginx-xmlrpc
logpath = /var/log/nginx/access.log
maxretry = 20
findtime = 10m
bantime = 1h
action  = %(action_mwl)s

We’re allowing a few mistakes on login (5) and a few more for XML-RPC (20). You can tune these later. The idea is to catch machines that keep pushing past Nginx’s limits. If you prefer progressively longer bans, enable bantime.increment in Fail2ban’s configuration.

Step 8: Add filters that recognize abuse by endpoint and status

Now define filters to detect repeated 429/403 on these endpoints. Create two files:

/etc/fail2ban/filter.d/nginx-wp-login.conf

[Definition]
failregex = ^<HOST> .* "POST /wp-login.php HTTP/S+" (403|429)
            ^<HOST> .* "GET /wp-login.php HTTP/S+" (403|429)
# Optionally also catch repeated 401 from basic auth if you protect /wp-admin
ignoreregex =

/etc/fail2ban/filter.d/nginx-xmlrpc.conf

[Definition]
failregex = ^<HOST> .* "POST /xmlrpc.php HTTP/S+" (403|429)
ignoreregex =

These patterns are built for the main_ext log format we defined earlier. If your format is different, adjust accordingly. The key idea: count repeated rate-limited or forbidden requests from the same IP to the specific endpoints.

Restart Fail2ban to load the jails:

sudo systemctl restart fail2ban

And confirm they’re active:

sudo fail2ban-client status
sudo fail2ban-client status nginx-wp-login
sudo fail2ban-client status nginx-xmlrpc

You’ll see the number of currently banned IPs and the log file being watched. When bots keep poking, they’ll get a short vacation outside your perimeter.

Step 9: Optional—recidivists and longer bans

Some botnets learn your limits and come back later. If you want to be firmer, try a recidive jail that watches all jails and hands out longer bans to repeat offenders. It’s simple, but effective if you’re tired of familiar faces in your logs.

If you want a deeper dive into firewall policy and pushing Fail2ban bans into nftables with smart rate limits, my friendly walkthrough on nftables firewall rules without the drama pairs nicely with this setup.

XML-RPC realities: when to block, when to throttle, and how to be kind

Let’s talk XML-RPC, because it’s the one that trips folks up. If you’re not using it, blocking it outright is clean and safe. But many sites do rely on it—mobile apps, Jetpack, remote editors, and some security services need it.

Here’s a low‑drama approach I’ve used: throttle XML-RPC POST requests with Nginx (as we did), and teach Fail2ban to ban IPs that keep tripping that limit. If you need certain services to bypass limits, allowlist their IPs temporarily using the map trick from earlier. I don’t love IP allowlists for third parties, because they change. But in a pinch, it works.

If the only XML-RPC method you actually need is something minimal, consider blocking specific methods known for abuse. The classic troublemaker is pingback.ping. Unfortunately, Nginx doesn’t parse XML, so we either do a light content match or rely on the app layer to disable pingbacks. I’ve set body matches for “pingback.ping” on small sites without issue. For high‑traffic sites, disabling pingbacks at the application level is cleaner and avoids scanning the body entirely.

What about system.multicall? It’s the Swiss army knife attackers use for batching. Rate limiting largely neuters it, because every POST is still a POST. If they squeeze hundreds of auth trials into one POST, that’s on them—you still counted 1 request. If that single request becomes expensive for PHP, you might block requests with very large bodies, or detect the method name via a short match and deny. I try rate limiting first; only escalate to body inspection when it’s absolutely necessary.

Tuning without hurting real users

Here’s where experience pays off. You want to be tough, but not moody. I once rolled out an aggressive login limit for a WooCommerce site and heard from the support team within the hour: some customers behind a corporate proxy shared a public IP, and a burst of legitimate password resets hit the same limit. Oops. It wasn’t a disaster, but it was a reminder.

A few practical knobs you can turn:

First, ease into the limits. Start with something like 5 requests/min for wp-login.php and 15/min for xmlrpc.php, then tighten if the bots keep chewing CPU. Don’t push to extremes on day one. You can always turn the dial as you observe real behavior.

Second, protect known flows. If you have a busy support team that logs into the dashboard from a single office IP, allowlist that IP or a VPN range using the map. That way, when everyone rushes to fix something at once, they don’t trip over each other.

Third, be kind to password reset flows. People do make mistakes typing passwords, especially on mobile. One or two retries is normal. Maxretry 5 within 10 minutes on Fail2ban is a forgiving baseline. If your user base is particularly non‑technical, consider stretching it a bit.

Fourth, remember IPv6. Many bots use v4, but not all. Ensure your stack bans both families. Fail2ban’s nftables actions handle v6 well. Double‑check that your web server logs show the real IPv6 addresses so the filters work properly.

Testing the setup like a friendly attacker

Never deploy security changes without a quick dry run. I like to test in a controlled way with curl. For example, simulate a burst to wp-login.php:

# Try multiple POSTs in a quick loop from your machine
for i in {1..10}; do 
  curl -s -o /dev/null -w "%{http_code}n" 
  -d "log=admin&pwd=guess$i" 
  https://example.com/wp-login.php; 
  sleep 0.2; 
done

Watch for 200/302 in the first few attempts (depending on how WordPress responds) followed by 429 as Nginx begins rate limiting. If you see only 200s forever, your limit likely didn’t apply. Double‑check the location block, especially the conditional on POST requests.

Then check Fail2ban status after a few minutes of abuse:

sudo fail2ban-client status nginx-wp-login

You should see your IP banned (so maybe run the test from a safe IP you don’t mind banning temporarily). Unban yourself easily:

sudo fail2ban-client set nginx-wp-login unbanip <your-ip>

Repeat a similar test for xmlrpc.php. If you’ve blocked pingbacks with the content match, try posting a payload containing “pingback.ping” to verify it returns 403.

Monitoring and clean logs: keep it visible and quiet

Nothing earns trust like clean observability. A few habits I swear by:

First, keep an eye on 429 rates. An unexpected spike might mean a real feature is tripping limits. Maybe a mobile editor gone wild, or a new service that started using XML-RPC. Briefly bump the rate if needed while you investigate.

Second, read your Fail2ban logs. You want to see a healthy rhythm—bans happening here and there, not thousands of IPs getting banned out of nowhere. If bans explode, check your Nginx real IP settings first. Nine times out of ten, that’s the culprit.

Third, consider centralizing logs so you don’t have to SSH into boxes at 2 a.m. A simple Loki + Promtail + Grafana setup gives you dashboards and quick searches by status code and endpoint. If that sounds appealing, I’ve shared how I set it up with practical retention and alarms in this guide to centralized logging without the drama.

Edge cases: CDNs, load balancers, and multiple app servers

Real life is messy. If you run behind a CDN, make absolutely sure you restore client IPs with the correct header and every proxy range added to set_real_ip_from. Otherwise, you’ll ban the CDN POP and take down real traffic with it. Been there, didn’t love it. The Cloudflare guide I linked earlier is a good sanity check.

If you use multiple app servers behind a load balancer, consider where to apply rate limiting. Per‑server limits can be fine, but a single client could be allowed N requests per minute per server. If you want global limits, put them at the load balancer or CDN edge. Nginx’s shared memory zones are per worker on that machine—they don’t magically sync across nodes.

Another subtlety: if you terminate TLS at a proxy and pass traffic to Nginx over plain HTTP, make sure logs still contain the original request path and method as usual. Fail2ban relies on that “POST /wp-login.php” string; if your logs are customized away from that format, adjust the filters to match.

A friendly checklist to wrap it up

Let’s pull the thread together. Here’s how I’d roll this out calmly on a typical VPS running Nginx and PHP‑FPM:

First, confirm Nginx sees real client IPs. If you use Cloudflare or a similar service, configure the real_ip_header and set_real_ip_from ranges. This one step prevents tragic, self‑inflicted bans.

Second, add a clear log format and keep your access log in a predictable place. Fail2ban’s power comes from reading these lines—don’t make it guess.

Third, define limit_req zones for login and XML-RPC, and apply them only to POST requests on those exact locations. Keep the initial rates reasonable; adjust later with data.

Fourth, create Fail2ban jails that look for repeated 429 or 403 responses on those endpoints. Set findtime and bantime to something humane, and consider bantime.increment if you want repeat offenders to stay out longer.

Fifth, test it. Trigger a few 429s on purpose, watch Fail2ban ban you, then unban. Tweak the rates and retries until you’re confident you’re defending without annoying real users.

Sixth, keep an eye on it. If you see spiky 429s or a sudden wave of bans, investigate. Usually it’s a misconfig, but sometimes a real campaign is underway. Either way, you’ll know.

Common tweaks and niceties I’ve learned over time

Here are a few more little tricks that have served me well:

Consider adding a separate access log just for the sensitive endpoints. It makes Fail2ban filters simpler and your troubleshooting faster. For example:

access_log /var/log/nginx/auth_endpoints.log main_ext;

location = /wp-login.php {
    access_log /var/log/nginx/auth_endpoints.log main_ext;
    # ... rest of the config ...
}

location = /xmlrpc.php {
    access_log /var/log/nginx/auth_endpoints.log main_ext;
    # ... rest of the config ...
}

This way, your main access log stays cleaner, and Fail2ban can watch a small file that rolls faster.

If you’re worried about accidental lockouts from your own team, publish a tiny “break glass” procedure: what to do if someone gets banned. It might be as simple as “message the on‑call” and run one command to unban the IP. Moving fast when it happens turns a panic into a non‑event.

If you want to get fancy, you can feed Fail2ban decisions into your firewall at a lower level with nftables sets for even cheaper packet drops. That’s overkill for many sites, but glorious for busy ones.

And finally, keep your WordPress itself sane: strong passwords, 2FA for admins, limit administrator accounts, and consider a login path change only if your staff won’t be confused by it. I don’t rely on security through obscurity, but making the door a little less obvious can reduce noise for smaller sites.

A quick word on documentation and going deeper

When I first stitched this together years ago, I hopped between documentation pages with a notepad full of scribbles. These days, it’s more muscle memory, but I still appreciate crisp references. If you want to read more straight from the source, the Nginx limit_req docs and the Fail2ban documentation are solid places to dip into details or explore alternative actions and jail configurations.

Wrap‑up: a calmer WordPress, without the drama

When I think back to that noisy log and the admin pages that felt like they were wading through syrup, I’m reminded how effective small, focused changes can be. You don’t need a sprawling security stack to tame brute force noise on WordPress. A thoughtful combination of Nginx rate limiting and Fail2ban shuts down most of the nonsense before it ever tickles PHP.

Start with real client IPs, add targeted limits to wp-login.php and xmlrpc.php, and let Fail2ban handle chronic offenders. Test gently, tune based on what you see, and keep your logs close at hand. If XML-RPC is essential for your workflow, throttle it kindly and consider blocking just the risky methods. And if you want to push bans deeper into the firewall or keep an eye on everything centrally, you’ve got clear next steps.

Hope this was helpful! If you try this setup and hit a weird edge case, I’d love to hear about it—there’s always a new trick to learn. Until then, may your logs be quiet and your admin snappy.

Frequently Asked Questions

Great question! Set the limits to match normal behavior and you’ll be fine. I start around 3–5 POSTs per minute for wp-login.php. Real people rarely submit more than twice in a minute unless they’re frustrated. You can also allowlist your office or VPN IPs so the team never feels the pinch. The key is to test, watch for 429s, and tune gently.

It depends on your setup. If you don’t use Jetpack, the mobile app, or remote publishing tools, returning 403 for /xmlrpc.php is the cleanest option. If you need it, throttle it with Nginx and let Fail2ban ban abusers, and optionally block just the pingback.ping method. If a specific service must bypass limits, allowlist its IP temporarily—but keep an eye on changes.

Make sure Nginx sees the real client IP. For Cloudflare, add all their IP ranges with set_real_ip_from and use CF-Connecting-IP. If your app sits behind another proxy, use its equivalent header. Otherwise, Fail2ban will see only the proxy’s IP and might ban it, which blocks everyone. Test this first—curl a request and confirm the access log shows your true IP.