Skip to content
  • FG-82
  • 13 min read
  • Last checked November 8, 2026

How to Stop Brute-Force Attacks on wp-login.php and xmlrpc.php

Thousands of POSTs to wp-login.php and xmlrpc.php are slowing your site. Measure the attack in your logs, then layer 2FA, attempt limits, an xmlrpc block and edge rules.

Run the health check

Your site has turned sluggish, the host says you are hitting CPU or PHP worker limits, or a security plugin keeps emailing you about lockouts. When you open the access log you see the same two lines over and over: "POST /wp-login.php HTTP/1.1" 200 and "POST /xmlrpc.php HTTP/1.1" 200, from dozens or thousands of IP addresses. That is a brute-force attack: automated software trying username and password pairs against your login, around the clock.

Almost every public WordPress site receives this traffic. It is rarely aimed at you personally; bots work through huge lists of domains and leaked credentials. The danger is twofold: one weak or reused password lets them in, and even when every guess fails, the flood of uncached PHP requests can slow or stall the whole site. This guide shows you how to measure the attack, then layer defenses so it stops costing you anything.

What is happening

WordPress accepts a password in several places. The two that bots hammer are the login form and the XML-RPC endpoint. Both run the full WordPress stack on every request, and neither can be served from a page cache, because each attempt carries different form data.

Endpoint How a guess is sent What the log shows
/wp-login.php A form POST with log and pwd fields Failed guess: 200 (the form is shown again with an error). Successful login: 302 redirect to wp-admin.
/xmlrpc.php An XML document calling a method such as wp.getUsersBlogs with a username and password Almost always 200, success or failure, because the error is inside the XML response body
REST API with Application Passwords HTTP Basic authentication header on a /wp-json/ request 401 on failure

XML-RPC has an extra trick called system.multicall: one HTTP request can bundle many method calls. Attackers used it to pack hundreds of password guesses into a single request, which slipped past tools that count requests. Current WordPress blunts this: once one login inside a request fails, the XML-RPC server refuses every later login attempt in that same request without checking the password. Bots still use the endpoint heavily, and every request still loads WordPress, so it remains worth blocking or limiting.

Why does it slow the site? Each attempt occupies a PHP worker while WordPress loads plugins, queries the database and checks a password hash. Password hashing is slow on purpose (WordPress 6.8 moved to bcrypt) so that stolen hashes are hard to crack. A few attempts per second, multiplied across a botnet, can keep every worker busy, and real visitors queue behind them. When workers run out you see 503 or 508 errors, which the resource limits guide (FG-14) covers. If the slowdown is from crawlers and scrapers rather than login attempts, see the bots and CPU guide (FG-36).

Before you start

  • Access you need: WordPress admin, SSH or your hosting file manager, and the access logs. On a VPS you also need root or sudo for fail2ban. For the Cloudflare steps you need access to the zone in the Cloudflare dashboard.
  • Backup: take a backup before changing .htaccess, server config or plugins. A typo in .htaccess can take the site down with a 500 error.
  • How to run commands: connect over SSH and change into the site folder, the one that contains wp-config.php. WP-CLI is wp on most servers and wp-cli on some managed hosts. Log commands (awk, grep) run on the server where the logs live. curl runs on your own computer: macOS and Linux have it built in; on Windows use WSL or curl.exe in PowerShell.
  • Log locations: Nginx usually writes to /var/log/nginx/access.log. Apache uses /var/log/apache2/access.log on Debian and Ubuntu and /var/log/httpd/access_log on RHEL-family systems. On shared hosting, look for an “Access logs” or “Raw access” section in the control panel, or a logs folder in your home directory.

Step 1: Confirm the attack in your access logs

Count login POSTs per IP

Run this on the server, replacing the placeholder with your log path:

awk '$6 == "\"POST" && ($7 ~ /^\/wp-login\.php/ || $7 ~ /^\/xmlrpc\.php/) {print $1, $7}' ACCESS_LOG | sort | uniq -c | sort -rn | head -25

Placeholder: ACCESS_LOG is the full path to your access log, for example /var/log/nginx/access.log. How it works: in the standard “combined” log format, field 1 is the client IP, field 6 is the quoted method (so it reads "POST with a leading quote) and field 7 is the requested path. uniq -c counts identical lines and sort -rn puts the biggest counts first. Good output: a few lines, each with a small count, from IPs you recognize as your own team. Bad output: single IPs with hundreds or thousands of POSTs, or a long tail of IPs each making 20 to 50 attempts. The long tail is a distributed attack, and it is why per-IP blocking alone is never enough.

If every line shows the same few IPs and they belong to Cloudflare or your host’s proxy, your server is logging the proxy address instead of the visitor. Restore the real IP first (Apache mod_remoteip, Nginx real_ip_header CF-Connecting-IP with set_real_ip_from for each Cloudflare range) or every IP-based defense below will block the proxy, not the attacker.

Separate failures from successes

awk '$6 == "\"POST" && $7 ~ /^\/wp-login\.php/ {print $9}' ACCESS_LOG | sort | uniq -c

Field 9 is the HTTP status code. Good output: a large number next to 200 (failed guesses) and a small number next to 302 that matches how often you and your team logged in. Bad output: 302 responses from IPs that are not yours. A 302 after a POST to the login form means a password was accepted. Find those lines with grep '"POST /wp-login.php' ACCESS_LOG | grep '" 302 ', note the IP and time, and treat it as a compromised account (FG-84 for one account, FG-05 for a full hack response). A 403 or 429 means something (a plugin, the host’s firewall) is already blocking attempts.

See when it started

grep -E '"POST /(wp-login|xmlrpc)\.php' ACCESS_LOG | awk '{print substr($4, 2, 14)}' | sort | uniq -c

This prints a count per hour, using the timestamp in field 4 (for example 07/Nov/2026:14 means 14:00 to 14:59 in the server’s time zone). Good output: a low, steady background. Bad output: a jump that lines up with the hour your site slowed down. Note that hour; your host will want it.

Step 2: Make guessing useless with strong passwords and 2FA

Blocking traffic reduces load. Only strong credentials stop a break-in. Do this first, even before the attack is under control.

Find accounts that matter

wp user list --role=administrator --fields=ID,user_login,user_email,user_registered

Run this over SSH in the site folder. --role=administrator limits the list to admins and --fields picks the columns. Repeat with --role=editor and --role=shop_manager if you run WooCommerce. Good output: only people who need that level of access. Bad output: an account called admin, former staff, or an address you do not recognize. A login you cannot explain is a compromise, not a cleanup task: go to FG-84.

Passwords

Every account with publishing rights or higher needs a long, unique password generated by a password manager. “Unique” matters most: most successful attacks use passwords leaked from other sites, so a long password reused elsewhere is still weak. Do not rely on renaming the admin user alone; bots can often learn usernames from author archive URLs and the public users endpoint of the REST API.

Two-factor authentication

2FA means a stolen password is not enough. Good options include the Two Factor plugin maintained by WordPress contributors, Wordfence Login Security, and the login protection in most major security plugins. Use an authenticator app or a security key rather than email codes where you can. Require 2FA for administrators at minimum, and generate backup codes so you are not locked out (FG-20 covers lockouts). Note that 2FA plugins protect the login form; Application Passwords and XML-RPC are separate doors, so close or limit them as well (Step 4).

Step 3: Limit login attempts

Limiting attempts slows guessing to a crawl. You can do it in WordPress or, better, in front of it.

In WordPress (any hosting)

A plugin such as Limit Login Attempts Reloaded, or the brute-force settings in Wordfence, Solid Security or similar, locks out an IP or username after a few failures. Sensible settings: 5 failures, a 20 minute lockout, longer lockouts for repeat offenders. The limit of this approach is that each blocked attempt still loads WordPress and runs PHP, so it protects accounts better than it protects performance.

At the server (best for performance)

Blocking before PHP runs is far cheaper. Your host may already do this with ModSecurity rules or a WAF; ask them. On a VPS, fail2ban is the standard tool (see the Advanced section). Another strong option is a second password on the login page itself using HTTP Basic authentication in Apache or Nginx, which stops bots before WordPress loads. It adds a browser prompt for everyone who logs in, so it suits sites with a small team better than membership sites or shops where customers log in.

Step 4: Block or limit xmlrpc.php

Decide whether you need it

XML-RPC predates the REST API. Today it is mostly used by Jetpack (its connection depends on it), some older desktop and mobile publishing apps, and pingbacks. If you use none of these, block it. If you use Jetpack, keep it reachable and rely on rate limits and challenge rules for everything else.

Confirm whether anything legitimate uses it by checking which user agents hit it:

grep '"POST /xmlrpc.php' ACCESS_LOG | awk -F'"' '{print $6}' | sort | uniq -c | sort -rn | head

Splitting on double quotes makes field 6 the user agent string. Good output (safe to block): generic or fake browser strings, or Python and Go client libraries. Caution: a Jetpack by WordPress.com agent means Jetpack needs it.

How to block it

The exact .htaccess and Nginx deny blocks are in the bots guide (FG-36), so they are not repeated here. Two points matter for brute force specifically. First, the WordPress filter xmlrpc_enabled only disables methods that need a login, and every request still loads WordPress, so a server-level block is better for load. Second, after blocking, verify it from your computer:

curl -s -o /dev/null -w "%{http_code}\n" -X POST https://example.com/xmlrpc.php

Replace example.com with your domain. -X POST sends a POST like a bot would, -o /dev/null discards the body and -w prints only the status code. Good output: 403. Bad output: 200, which means requests still reach WordPress (check that your rule is in the right file and that a cache or proxy is not answering first).

Step 5: Rate limit or challenge the login at Cloudflare

If your DNS is proxied through Cloudflare (orange cloud), you can stop most attempts at the edge, before they reach your host at all. Cloudflare’s current dashboard keeps these under Security, then Security rules, where Create rule offers both Custom rules and Rate limiting rules.

A custom rule for XML-RPC and the login page

Create a custom rule with this expression (use Edit expression to paste it):

(http.request.uri.path eq "/xmlrpc.php")

Set the action to Block if you do not use XML-RPC. Then create a second rule for the login page:

(http.request.uri.path eq "/wp-login.php" and not ip.src in {YOUR_OFFICE_IP})

Placeholder: YOUR_OFFICE_IP is your own public IP address, which you can find by searching “what is my IP” from your network; remove the and not ip.src in {...} part if your IP changes often. Set the action to Managed Challenge. Humans pass in a second or two; most bots cannot. Good result: under Security, then Analytics or Events, you see the rule matching thousands of requests while your own logins still work. Bad result: legitimate logins failing, usually because a login form on another page posts to wp-login.php from an app that cannot solve challenges. The Free plan includes five custom rules.

A rate limiting rule

On the Free plan you get one rate limiting rule, matched on URI path, counted per IP over 10 seconds, with a 10 second block. That is enough to blunt rapid-fire bots: set URI Path equals /wp-login.php, a limit of about 5 requests per 10 seconds, action Block. Pro raises the counting period to one minute and the block to one hour. Business and higher plans also let you match on the request method (so you count only POSTs) and count by response code. On those plans, count POSTs to /wp-login.php that return 200 (failed logins) and block for 10 minutes or more.

Step 6: Change the login URL, and know what it does not do

Plugins such as WPS Hide Login move the login form to a custom path and return a 404 at /wp-login.php. This genuinely reduces log noise and wasted PHP, because most bots only try the default path. But it is obscurity, not security. The custom URL leaks through password reset emails, browser history and some plugins; it does nothing for XML-RPC or Application Passwords; and it does not make a weak password strong. Use it as a noise filter on top of Steps 2 to 5, never instead of them. Record the new URL somewhere your whole team can find it.

Advanced: a fail2ban jail for WordPress logins on a VPS

fail2ban watches log files and adds a firewall ban when an IP matches a pattern too often. Because it works from the web server’s access log, it needs no WordPress plugin. Only use this on a server you control; on shared hosting you cannot run it.

Create a filter file at /etc/fail2ban/filter.d/wordpress-login.conf:

[Definition]
failregex = ^<HOST> \S+ \S+ \[[^\]]+\] "POST /wp-login\.php[^"]*" 200
            ^<HOST> \S+ \S+ \[[^\]]+\] "POST /xmlrpc\.php[^"]*" 200
ignoreregex =

How it reads: <HOST> is fail2ban’s token for the IP address it will ban. The first pattern matches a POST to the login form that returned 200, which is a failed login, so your own successful logins (status 302) never count. The second matches XML-RPC POSTs, which always return 200; leave it out if Jetpack or a publishing app needs XML-RPC, or they may be banned. This assumes the standard combined log format.

Then add a jail in /etc/fail2ban/jail.local (create it if missing; never edit jail.conf, which updates overwrite):

[wordpress-login]
enabled  = true
port     = http,https
filter   = wordpress-login
logpath  = ACCESS_LOG
maxretry = 5
findtime = 10m
bantime  = 1h

Placeholder: ACCESS_LOG is the full log path from Step 1. If you host several sites with separate logs, list each path on its own indented line. Settings: maxretry is how many matches trigger a ban, findtime is the window they must fall in, and bantime is how long the ban lasts. Test the filter against your real log before you enable it:

sudo fail2ban-regex ACCESS_LOG /etc/fail2ban/filter.d/wordpress-login.conf
sudo fail2ban-client reload
sudo fail2ban-client status wordpress-login

fail2ban-regex reports how many lines matched. Good output: a matched count close to the failed POST count you found in Step 1, and “Lines: … missed” covering everything else. Bad output: zero matches, which usually means a different log format or a date it cannot parse. After the reload, status lists currently banned IPs. To release an IP you banned by mistake, run sudo fail2ban-client set wordpress-login unbanip IP_ADDRESS, where IP_ADDRESS is the address to release.

Behind Cloudflare, take care. A firewall ban on the server blocks the address the connection came from, which is a Cloudflare edge IP, not the attacker. That bans innocent visitors and does not stop the bot. Either rely on Cloudflare rules for edge blocking, or use fail2ban’s Cloudflare action that adds the ban through Cloudflare’s API, together with real IP logging.

When to contact your host

Open a ticket if the site stays slow after these steps, if you cannot read your logs, or if you saw a successful login you cannot explain. Include:

  • The UTC start time of the attack and whether it is ongoing.
  • Your top attacking IPs with counts, from Step 1.
  • The share of POSTs to wp-login.php versus xmlrpc.php.
  • Any 302 login responses from unknown IPs, with timestamps.
  • What you have already enabled (plugin limits, Cloudflare rules, xmlrpc block) and when.
  • Whether you use Jetpack or any app that needs XML-RPC.

Ask whether they can block xmlrpc.php or rate limit wp-login.php at their firewall, which is cheaper than anything you can do inside WordPress. To check your DNS, headers and security setup from outside, our diagnostic tools can help; if the site is down right now, start at the Emergency Room.

Still stuck?

Print a free one-page checklist to keep, or open the Emergency Room if the symptom does not match.

Get the free checklists

Keep reading

All field guides
FG-85 Security

Website Under a Traffic Flood or DDoS Attack: What to Do

Your site is buried under traffic. Tell an attack from a viral spike, put Cloudflare in front, challenge or rate limit the hot paths, and lock your origin so it cannot be bypassed.

Read the guide
13 min read

Search the field manual

What is going wrong?

Press / to search from any page, Esc to close