Brute force protection in WordPress: block wp-login.php and xmlrpc.php attacks

A layered defence for the two URLs that take almost all the brute-force traffic on a WordPress site. Rate limit at the edge, harden authentication in WordPress, and keep Jetpack, mobile apps and Application Passwords working while you do it.

You opened your access logs and saw the same thing every WordPress operator eventually sees: a wall of POSTs to /wp-login.php from IPs you do not recognise, or a burst of traffic to /xmlrpc.php that made PHP-FPM workers spike and wp-admin go sluggish. The goal of this article is simple and narrow: stop that hammering right now, prevent recurrence, and do it without breaking Jetpack, the mobile app, or the integrations that quietly depend on XML-RPC.

The short version is that brute force against WordPress is a three-layer problem and needs a three-layer answer. Edge or server-level rate limiting stops traffic before PHP loads, authentication hardening (2FA, Application Passwords, unique credentials) protects the login flow itself, and monitoring tells you whether the other two layers are holding. Any single layer on its own fails under a real attack; all three together reduce an active incident to a shrug.

This article is the specific how-to for the two URLs that take the brute-force traffic. The broader checklist lives in WordPress security hardening, which you should skim first if you have never touched security on this site before.

What a brute force attack looks like in your logs

The symptoms are consistent enough that you can recognise them in thirty seconds of log tailing. On nginx, tail -f /var/log/nginx/access.log | grep -E "(wp-login|xmlrpc)" during an active attack shows either pattern below.

wp-login.php credential stuffing looks like hundreds to thousands of POSTs per minute to the same URL, from many IPs, each trying a different username/password combination:

203.0.113.42 - - [09/Apr/2026:14:22:11 +0200] "POST /wp-login.php HTTP/1.1" 200 3124
198.51.100.7 - - [09/Apr/2026:14:22:11 +0200] "POST /wp-login.php HTTP/1.1" 200 3124
192.0.2.188 - - [09/Apr/2026:14:22:12 +0200] "POST /wp-login.php HTTP/1.1" 200 3124

The response code is usually 200 (WordPress renders the login page again with an error) and the body size is near-constant. Traditional IP-based rate limiting catches this pattern easily because the request volume is obvious.

xmlrpc.php amplification looks very different and is much sneakier. A handful of POSTs, sometimes only three or four per minute, with large request bodies:

203.0.113.42 - - [09/Apr/2026:14:22:11 +0200] "POST /xmlrpc.php HTTP/1.1" 200 512 "-" "Mozilla/5.0"
203.0.113.42 - - [09/Apr/2026:14:22:41 +0200] "POST /xmlrpc.php HTTP/1.1" 200 512 "-" "Mozilla/5.0"

Each of those requests can carry a system.multicall payload that tests hundreds of passwords in a single HTTP round-trip. Sucuri's original disclosure and Cloudflare's follow-up analysis document the mechanics: a single POST used to carry up to 1,999 authentication attempts. Request-count rate limiting misses this because the request count is small; you need method-level awareness or aggressive xmlrpc.php throttling.

Side effects to expect during either pattern: PHP-FPM workers saturate, CPU usage climbs, wp-admin becomes slow or unreachable, and your host may send you a throttling notice. The high CPU usage article explains why: every request that reaches PHP spends a worker slot regardless of whether it is a legitimate visitor or a brute force bot, and an exhausted worker pool queues everything.

Why these two URLs, and what the attackers are actually doing

wp-login.php is the interactive login endpoint. Every WordPress site has it at the same path, it accepts POSTs without any rate limiting out of the box, and it responds with a distinguishable success or failure signal. Automated botnets run dictionary attacks against it at scale, cycling through common usernames (admin, wordpress, your brand name) and common password lists.

xmlrpc.php is the legacy XML-RPC endpoint. It has been enabled by default in every WordPress version since 3.5 (December 2012), which the Kinsta XML-RPC documentation confirms. The reason it is a brute force magnet is that XML-RPC is a non-interactive protocol: it cannot benefit from 2FA, CAPTCHA, or any of the interactive anti-automation protections that sit in front of wp-login.php. An attacker who has the right method can run an authenticated credential test against xmlrpc.php and the normal WordPress login protections never execute.

Two historical notes shape how you need to think about the xmlrpc.php path today.

The system.multicall amplification window. Pre-WordPress 4.4, system.multicall let an attacker batch up to 1,999 authentication attempts in one HTTP POST. WordPress Trac ticket #34336 patched this in WordPress 4.4 (December 2015): after the first authentication failure within a multicall batch, all subsequent calls in that batch abort immediately. Sites on WordPress 4.4 or newer do not face the full amplification problem, but the attack still exists as slow, distributed sequential XML-RPC calls. If you are running anything older than 4.4, stop reading and update first; the rest of this article will not save you.

Jetpack and legitimate XML-RPC traffic. Jetpack still uses XML-RPC for its communication with WordPress.com as of Jetpack's own XML-RPC documentation, last updated May 2025. It uses token-based authentication rather than transmitting user credentials, which is a genuine security distinction, but it still goes over xmlrpc.php. A blanket server-level block of xmlrpc.php will break Jetpack. The WordPress mobile app on older versions also used XML-RPC, though current versions have moved to the REST API. Any third-party publishing tool (MarsEdit, Windows Live Writer legacy installs) and some older WooCommerce integrations may still depend on it as well.

The practical implication is that you have two strategic choices on xmlrpc.php. Either you do not use any of the legitimate XML-RPC features and you block the whole endpoint at the web server, or you keep it reachable and you rate-limit it aggressively while disabling the specific dangerous methods. The rest of this article covers both paths.

Layer 1: rate limit at the server or the edge

The fastest, cheapest, most effective layer is request rate limiting that happens before PHP loads. Every request that is rejected at this layer costs no PHP worker time and cannot saturate your worker pool. This matters because plugin-based defences run inside WordPress, which means the WordPress bootstrap, database connection and plugin load all happen before the block fires. Under a real attack, that is already too late.

Pick the layer that matches your infrastructure. You only need one of these three; running all three is fine but not required.

nginx: limit_req

Nginx ships with a limit_req module that rate limits by client IP. The WordPress Advanced Administration brute force guide publishes the canonical snippet. Add this to your site's nginx config, then reload nginx with nginx -t && systemctl reload nginx:

# In the http {} block, once per server
limit_req_zone $binary_remote_addr zone=logins:10m rate=10r/m;
limit_req_zone $binary_remote_addr zone=xmlrpc:10m rate=20r/m;

# In your WordPress server {} block
location = /wp-login.php {
    # Allow up to 20 requests in a burst, no delay for the first 20
    limit_req zone=logins burst=20 nodelay;
    # Hand the request to PHP-FPM as usual
    include snippets/fastcgi-php.conf;
    fastcgi_pass unix:/run/php/php8.2-fpm.sock;
}

location = /xmlrpc.php {
    # xmlrpc.php is chattier; tune this to your Jetpack traffic pattern
    limit_req zone=xmlrpc burst=10 nodelay;
    include snippets/fastcgi-php.conf;
    fastcgi_pass unix:/run/php/php8.2-fpm.sock;
}

The 10r/m rate on wp-login.php means each client IP is allowed ten requests per minute, with a burst allowance of twenty. An attacker making five requests per second is allowed the first twenty to pass without delay, then every further request in that burst is rejected with 503 Service Unavailable. Legitimate logins almost never trip the limit; a human being who mistypes their password four times does not hit ten requests per minute.

You will know it worked when a quick attack simulation hits the limit. From a second machine run for i in $(seq 1 30); do curl -s -o /dev/null -w "%{http_code}\n" -X POST https://yoursite.nl/wp-login.php; done and watch the response codes flip from 200 to 503 after the burst is exhausted.

Apache: mod_ratelimit or mod_security

Apache has no built-in equivalent to limit_req. The two supported paths are mod_qos (third-party, designed for exactly this) and ModSecurity with the OWASP Core Rule Set DOS protection rules. The ModSecurity rules REQUEST-912-DOS-PROTECTION.conf detect request floods and apply a temporary block; set tx.dos_burst_time_slice, tx.dos_counter_threshold and tx.dos_block_timeout in the CRS setup file to values appropriate for your traffic.

A dedicated WordPress-oriented ModSecurity ruleset that extends OWASP CRS is the Rev3rseSecurity ruleset; it includes XML-RPC-specific rules for detecting authentication failures and blocking after N failures within a window. For Apache deployments without ModSecurity, the realistic answer is to put Cloudflare or another edge WAF in front and rate limit there instead.

Cloudflare (any plan, including free)

If your site already runs through Cloudflare, the fastest win is Cloudflare's Rate Limiting feature, which is available unmetered on all plans including free as of 2024. Configure two rules in the Cloudflare dashboard under Security > Rate Limiting Rules:

  1. Path contains /wp-login.php, threshold 5 requests per minute per IP, action Block for 1 hour.
  2. Path contains /xmlrpc.php, threshold 10 requests per 30 seconds per IP, action Block for 1 hour.

These values come from community consensus and WordPress VIP's production defaults (see below). They are tight enough to stop automated attacks and loose enough that Jetpack's polling does not trip them.

Cloudflare also maintains a managed WAF rule historically labelled WP0018 that detects system.multicall amplification patterns. It is available on Pro and above, not on the free plan. If you are on the free plan, the rate-limit rule above is the right substitute.

One Cloudflare gotcha that bites people later. When your site is behind Cloudflare, your nginx access logs show Cloudflare's IP addresses, not the real attacker IPs. If you are also running fail2ban (below), you need to configure nginx to log the CF-Connecting-IP header instead of $remote_addr, or fail2ban will ban Cloudflare. RunCloud's fail2ban + Cloudflare guide walks through the log format change.

Layer 2: the xml-rpc decision

Once the rate limit layer is live, decide what to do with xmlrpc.php strategically. Three options, in order of surgical precision.

Option A: block xmlrpc.php entirely (no Jetpack, no XML-RPC clients)

If you do not use Jetpack, you do not use an XML-RPC publishing tool, and your mobile app is modern (uses REST API + Application Passwords), block the file at the web server:

# nginx: return 444 drops the connection without a response
location = /xmlrpc.php {
    deny all;
    access_log off;
    log_not_found off;
    return 444;
}
# Apache: in .htaccess at the WordPress root, above the WordPress rewrite block

    Require all denied

Verify with curl -I https://yoursite.nl/xmlrpc.php. You should get 403 Forbidden, a dropped connection, or a 444 response. What you must not see is 200 OK with the body XML-RPC server accepts POST requests only.

Option B: selective method removal (keep Jetpack, remove the dangerous parts)

If you need Jetpack or any other XML-RPC integration, do not block the endpoint. Remove the specific methods that are abused instead. WordPress exposes the xmlrpc_methods filter, which lets you unset method names from the dispatch table. The following drops the two methods that matter for brute force and reflected DDoS, and leaves Jetpack's methods intact:

// In a mu-plugin at wp-content/mu-plugins/xmlrpc-harden.php
<?php
add_filter( 'xmlrpc_methods', function ( $methods ) {
    // system.multicall is the amplification vector. WP 4.4+ already
    // aborts after the first auth failure, but removing the method
    // entirely denies the attack its initial foothold.
    unset( $methods['system.multicall'] );
    // pingback.ping is abused for reflected DDoS: an attacker asks
    // your site to "pingback" a target URL, and your site attacks
    // the target on their behalf.
    unset( $methods['pingback.ping'] );
    unset( $methods['pingback.extensions.getPingbacks'] );
    return $methods;
} );

Important nuance about the xmlrpc_enabled filter. You will see advice online that suggests add_filter( 'xmlrpc_enabled', '__return_false' ); is the way to "disable XML-RPC". It is not. That filter only disables authenticated XML-RPC methods. It leaves unauthenticated methods like pingback.ping working. If you want full denial, you need server-level blocking (Option A). If you want selective hardening, use the xmlrpc_methods filter shown above, not xmlrpc_enabled.

Option C: rate limit xmlrpc.php and allowlist Jetpack IPs

For sites on WordPress VIP or other production-grade platforms, the pattern is tighter: allow only Jetpack's published IP ranges to reach xmlrpc.php and block all other requests. WordPress VIP's XML-RPC security controls documentation publishes the exact rules they use, including the 10 requests per 30 seconds per IP rate limit with a 1-hour block on violations. This is overkill for most self-hosted sites and excellent if you run a large publication that relies on Jetpack and cannot tolerate any XML-RPC attack surface.

Layer 3: authentication hardening

Rate limits stop the flood. Authentication hardening stops the request that gets through. This layer is where the actual login attempt meets the real WordPress, and it is the layer most site operators skip because "the rate limit handles it", right until an attack comes from a single IP slowly enough to slip past the limit.

Two-factor authentication on every administrator. WordPress core does not ship with 2FA. The Two-Factor plugin maintained by the core contributors is one right default: TOTP codes plus backup codes, no kitchen-sink feature bloat. WP 2FA by Melapress is the other right default for less technical teams. Enable one of them and enforce it for every user with edit_posts or higher. A brute-forced password without the second factor is worth nothing. The full walkthrough, including the grace period that keeps you from locking your team out on the day you turn enforcement on, is in two-factor authentication (2FA) for WordPress.

Application Passwords for anything that is not a human. Application Passwords were added in WordPress 5.6. Each one is a 24-character alphanumeric credential (142 bits of entropy), individually revocable, scoped per integration. They work with the REST API (see REST API security in WordPress for the user enumeration attack surface) and with XML-RPC, but not with interactive wp-login.php logins. Move every mobile app, CI pipeline, and third-party integration off your personal admin password and onto a per-integration Application Password. The point is that an application password is useless against an interactive login form and is therefore not a stepping stone into your main account.

One caveat. Application Passwords are a partial mitigation of the XML-RPC brute force problem, not a solution. Regular user passwords still work against xmlrpc.php today. WordPress Trac #62789 proposes enforcing Application Passwords for all authenticated XML-RPC requests (blocking regular user passwords from being used over XML-RPC), which would close the remaining credential-stuffing vector. As of April 2026 the ticket is open and unmerged, flagged as a breaking change requiring advance notice. It is worth watching; it is not yet shipped.

Limit Login Attempts and audit failures. Limit Login Attempts Reloaded is the minimal plugin option: count failed logins per IP, lock out after a threshold, log the attempt. Defaults are four attempts, twenty-minute lockout, escalated four-hour lockout after three rounds. Those defaults are fine. If you run a full security suite (Wordfence, iThemes Security, Sucuri), it already includes this feature; do not stack two plugins that both count failed logins against the same database.

Strong, unique administrator credentials. This is not a WordPress control; it is operational hygiene. Every administrator uses a password manager. Every administrator has a unique email address they personally control. Any shared admin@company.nl mailbox defeats the password-reset recovery path. Delete ex-employee accounts on the day they leave, not "soon".

The hardening article covers these in more detail; the short version is that a strong password plus 2FA plus Application Passwords for integrations is the authentication baseline, and nothing in this article replaces it.

Layer 4: monitoring with fail2ban and log review

Rate limits and hardening work silently. You need telemetry to know whether they are holding and whether the attack has shifted shape. Two practical tools do the job.

fail2ban for dynamic IP bans. fail2ban watches log files for patterns and drops matching IPs into iptables or nftables for a ban period. The pattern for WordPress brute force is to match repeated POSTs to /wp-login.php and /xmlrpc.php. A minimal filter (in /etc/fail2ban/filter.d/wordpress.conf):

[Definition]
failregex = ^<HOST> -.*"(GET|POST).*(/wp-login\.php|/xmlrpc\.php).*" 200
ignoreregex =

And the matching jail in /etc/fail2ban/jail.local:

[wordpress]
enabled  = true
filter   = wordpress
port     = http,https
logpath  = /var/log/nginx/access.log
maxretry = 5
findtime = 1800
bantime  = 86400
usedns   = no

Five hits within thirty minutes triggers a 24-hour ban. Tune bantime down to 3600 (one hour) if you want less friction, or up to a week for sites that see persistent attacks. Always test your regex with fail2ban-regex /var/log/nginx/access.log /etc/fail2ban/filter.d/wordpress.conf before enabling the jail.

Important fail2ban caveat. fail2ban operates at the OS/iptables level. It catches bans after the PHP worker has already been spawned and the request has already been logged. That is slower than server-level rate limiting (nginx limit_req blocks before PHP loads). Use fail2ban as the catch-all telemetry-and-retaliation layer, not as the primary defence. The nginx rate limit is faster.

If you are behind Cloudflare, fail2ban needs help. As noted above, your nginx logs will show Cloudflare IPs. Either configure nginx to log $http_cf_connecting_ip, or use Cloudflare Firewall Rules directly instead of fail2ban.

Log review cadence. Check your access logs weekly for the first month after applying these controls, then monthly. The point is to spot attack-shape changes: if an attack shifts from a single IP to a distributed botnet, your rate limit may need retuning or the attacker has moved to a layer you are not watching. A five-minute grep -c "POST /wp-login.php" /var/log/nginx/access.log.1 is usually enough to know whether the attack is still live.

Myths that waste your time

A short list of things that look like defences and are not.

"I changed the login URL to /my-secret-login/, so I'm safe." You reduced automated bot noise. You did not add a security control. Determined scanners include login-path discovery; the 2024 CVE list includes a "hide login URL" plugin vulnerability that leaked the hidden URL through a bug in the plugin itself. It is also useless against xmlrpc.php, which stays at the same path. Rate limiting and 2FA do the actual work.

"A security plugin alone stops brute force." No. Plugins run inside WordPress, so every request they see has already reached the PHP worker pool. Under a real attack the workers saturate on the plugin's own CPU cost, and the site goes down. Server-level or edge rate limits are required; plugins add authentication-layer intelligence on top of that, not instead of it.

"Blocking xmlrpc.php breaks nothing important." It does if you run Jetpack or any XML-RPC publishing tool. It is also a safe choice if you do not. The correct answer is "audit first, then block or filter". Do not paste a random deny all rule without knowing which parts of your site call xmlrpc.php.

"The xmlrpc_enabled filter disables XML-RPC." Only authenticated methods. Unauthenticated methods like pingback.ping keep working. If you need full disable, you need server-level blocking.

"Post-WordPress 4.4 the XML-RPC attack is fixed." The amplification is fixed: multicall aborts after the first auth failure. Distributed, slow, sequential XML-RPC brute force still works. The endpoint still needs rate limiting or selective blocking.

When to escalate

Call a specialist when any of these is true, and collect the list below before you do.

  • Rate limits are live but PHP-FPM workers are still saturating. Something is bypassing the limit, or the attack has moved to an endpoint you are not watching.
  • Your logs show an attack continuing from thousands of rotating IPs even after Cloudflare rate limiting is enabled. This is a distributed botnet and probably needs a Cloudflare Pro plan or a dedicated WAF rule set.
  • You cannot log in yourself because the rate limit is catching your legitimate traffic. See the cannot log in to WordPress article first; the issue may not be the brute force defence.
  • You suspect the attack already succeeded (unexpected admin users, unexpected posts, redirects to unrelated domains). Brute force protection is no longer the right question; you are in incident-response mode.
  • The site is down or intermittent under the attack and you need it back now.

Collect before you ask:

  • The WordPress version, PHP version, web server (nginx or Apache), and whether you are behind Cloudflare or another CDN.
  • Whether Jetpack, the mobile app, or any XML-RPC integration is in active use.
  • The last 500 lines of access log around the attack window (tail -500 /var/log/nginx/access.log), specifically filtered for wp-login.php and xmlrpc.php.
  • The rate-limit configuration you have tried so far (nginx limit_req_zone/limit_req lines, Cloudflare rule screenshots, or fail2ban jail config).
  • A list of active plugins with versions (wp plugin list --format=csv or wp-admin > Plugins).
  • Whether you have seen any of the symptoms in the PHP workers exhausted article, which is the downstream effect of an unmitigated brute force attack.

The complete server-level configuration, assembled

This is the full nginx snippet for a site that blocks xmlrpc.php entirely and rate-limits wp-login.php. Adapt the PHP-FPM socket path and WordPress root to your install.

# In the http {} block, once per server
limit_req_zone $binary_remote_addr zone=logins:10m rate=10r/m;

server {
    server_name yoursite.nl www.yoursite.nl;
    root /var/www/yoursite.nl;
    index index.php;

    # Block xmlrpc.php entirely: no Jetpack on this site
    location = /xmlrpc.php {
        deny all;
        access_log off;
        log_not_found off;
        return 444;
    }

    # Rate limit wp-login.php
    location = /wp-login.php {
        limit_req zone=logins burst=20 nodelay;
        include snippets/fastcgi-php.conf;
        fastcgi_pass unix:/run/php/php8.2-fpm.sock;
    }

    # Normal WordPress rewrite
    location / {
        try_files $uri $uri/ /index.php?$args;
    }

    location ~ \.php$ {
        include snippets/fastcgi-php.conf;
        fastcgi_pass unix:/run/php/php8.2-fpm.sock;
    }
}

For a site that keeps xmlrpc.php reachable for Jetpack, replace the location = /xmlrpc.php block with:

limit_req_zone $binary_remote_addr zone=xmlrpc:10m rate=20r/m;

location = /xmlrpc.php {
    limit_req zone=xmlrpc burst=10 nodelay;
    include snippets/fastcgi-php.conf;
    fastcgi_pass unix:/run/php/php8.2-fpm.sock;
}

And drop the mu-plugin from the Option B section into wp-content/mu-plugins/xmlrpc-harden.php to remove system.multicall and the pingback methods at the WordPress layer.

Reload nginx with nginx -t && systemctl reload nginx and verify with the curl commands from the verification notes earlier in this article. If the verification passes, the brute force traffic stops consuming PHP workers within the next request cycle, wp-admin speeds back up, and your logs go quiet.

Want fewer security surprises?

Staying safe is routine work: patching, monitoring, backups and defense-in-depth—done consistently.

See WordPress maintenance

Search this site

Start typing to search, or browse the knowledge base and blog.