How to Stop Brute Force Attacks on WordPress Login

Category

Time to Read

9 minutes

Published

Updated

To stop brute-force attacks on WordPress, rate-limit the login page so an attacker gets a handful of tries instead of thousands, and close the second door, XML-RPC, which lets them skip the limit. On our unprotected test site, a script made 200 wrong password guesses against the login page in 18 seconds, and every one got through. With a rate limit in place, the same run was cut off after about 21 tries.

Rate limiting works best at the server or CDN, before the request reaches PHP. This guide shows four layers of protection, each checked on a WordPress 7.1.3 lab site, and which ones to use depending on whether you have server access.

Why the WordPress login is easy to brute-force

By default, WordPress sets no limit on login attempts, so an attacker can try passwords as fast as the server answers. On our test site we ran a script that posted 200 wrong passwords to wp-login.php; it finished in 18 seconds, every request returned the normal 200 response, and the correct password right afterward logged in without trouble (test T1).

A real attack is faster and comes from many addresses at once (a botnet), so blocking one address is never the whole answer. But even simple rate limiting changes the maths: an attacker who gets 20 tries per address instead of unlimited tries needs vastly more addresses to work through a password list.

Two things make WordPress worse than a plain login form. First, usernames leak: on a default install, /?author=1 and the REST route /wp-json/wp/v2/users both reveal account login names, so an attacker rarely has to guess the username. Second, XML-RPC offers a faster door, covered below.

Stop the XML-RPC amplification first

Before you rate-limit the login page, deal with XML-RPC, because it lets an attacker bypass a login-page limit entirely. The system.multicall method packs many password guesses into one HTTP request.

On our test site, one system.multicall request carried 100 login attempts, and the server checked all 100 and returned 100 password-check faults from that single request (test T8). A rate limit that counts requests sees those 100 guesses as one request. So a limit on wp-login.php alone leaves this path wide open.

If nothing on your site needs XML-RPC, disable it; if something does (Jetpack, the mobile apps), restrict it. The full decision and the four methods are in disabling XML-RPC. For brute-force protection specifically, make sure XML-RPC logins are off or the file is rate-limited alongside the login page, as the server examples below do.

Layer 1: Limit login attempts with a plugin (no server access)

If you can’t touch the server, a login-limit plugin is the practical floor. It counts failed logins per address and locks that address out for a while after a threshold.

This is the weakest of the layers, for a reason worth knowing: a plugin runs inside PHP, so every blocked attempt still loads WordPress before the plugin can reject it. WordPress’s own guidance puts it plainly: app-level plugins “still execute within PHP and thus consume some resources under heavy attack,” and it says to prefer server- or edge-level throttling where possible (WordPress hardening docs). Under a real flood, the plugin protects the account but not the server’s CPU.

Use a maintained plugin, set the lockout to a handful of attempts, and treat it as one layer, not the whole defense. If you have any server access at all, add Layer 2.

Layer 2: Rate-limit at the web server (nginx or Apache)

If you control the web server, rate-limit the login page there, so most bad traffic is rejected before PHP runs. This is where WordPress’s docs point, and it is the layer that does the most for the least cost.

For nginx, the official example defines a shared zone and applies it to the login and XML-RPC paths:

# Count only POST login attempts, not visitors loading the form
map $request_method $wplogin_post {
    POST    $binary_remote_addr;
    default "";
}
limit_req_zone $wplogin_post zone=wplogin_post:10m rate=10r/m;

server {
    # ...
    location ~ ^/(wp-login|xmlrpc)\.php$ {
        limit_req zone=wplogin_post burst=20 nodelay;
        limit_req_status 429;
        include snippets/fastcgi-php.conf;
        fastcgi_pass unix:/run/php/php8.3-fpm.sock;
    }
}

Two details in that config came out of testing, not the manual.

First, the map block. WordPress’s published example rate-limits every request to wp-login.php, including the GET that shows the form. We tried it that way and a normal visitor loading the login page was given a 429 right after an attack (test T5). Counting only POST requests, with the map above, fixed it: after 200 wrong POSTs, the login form still loaded with a 200 for a real visitor, while the password guesses were cut off (test T6b). The attack posts; a visitor reading the page does not.

Second, naming. When we changed the zone’s key but kept the zone name, nginx workers kept using the old zone after a reload and limited the wrong requests (a discarded run, noted in our log). If you change a limit_req_zone key, give the zone a new name or fully restart nginx.

With this in place (test T6b), a 200-guess run was cut off: about 21 attempts got the normal response and the remaining ~179 got 429, including the one carrying the correct password. Seven seconds later, a single legitimate login succeeded. The limit slows an attacker to a crawl without locking out the real user for long.

For Apache, the equivalent needs a module such as mod_evasive or a ModSecurity ruleset, since core Apache has no built-in request-rate limiter. The simpler Apache option, when you can, is to allow the login page only from your own addresses:

<Files "wp-login.php">
    Require ip 203.0.113.15 203.0.113.16
</Files>

Replace those with your real IPs. This is the strongest protection of all, but only workable when every admin logs in from a known address.

Layer 3: Ban repeat offenders with fail2ban

To turn repeated failures into a real block at the firewall, fail2ban watches your access log and bans an address that fails too often. Where the rate limit slows every attacker a little, fail2ban removes a persistent one entirely for a while.

We set it up on the test server reading nginx’s access log, with a filter for failed logins (a POST to wp-login.php that returns 200, which is how WordPress answers a failed attempt) and a jail set to ban after 5 failures in 10 minutes for 1 hour:

# /etc/fail2ban/filter.d/wordpress-login.conf
[Definition]
failregex = ^<HOST> .* "POST /wp-login\.php[^"]*" 200
ignoreregex =
# /etc/fail2ban/jail.d/wordpress.local
[wordpress-login]
enabled  = true
filter   = wordpress-login
logpath  = /var/log/nginx/access.log
backend  = polling
maxretry = 5
findtime = 10m
bantime  = 1h

In our test (T7b), after 20 wrong logins the address showed as banned, and the next request to the site timed out at the network level rather than getting a web response, until we lifted the ban. That is the difference from a rate limit: a banned address can’t reach the site at all.

One gotcha caught us: fail2ban ignores the server’s own addresses by default (ignoreself = true), and all our test traffic came from 127.0.0.1, so the first run banned nobody. On a real site the attacker’s address is external and this doesn’t apply; our note in the lab log records the lab-only workaround.

Layer 4: Push it to the edge (CDN or WAF)

The best place to stop a brute-force flood is before it reaches your server at all, at a CDN or managed firewall. WordPress’s docs recommend exactly this: “Prefer edge/WAF protections (Cloudflare, Sucuri, host-provided WAF) so bad traffic is blocked before it reaches your server” (WordPress hardening docs).

A CDN can rate-limit the login path, show a challenge page (a CAPTCHA or Turnstile) to suspicious clients, and block known-bad addresses, all without using your server’s resources. If your site already runs through Cloudflare or a host that offers a WAF, a login-path rule there is the single most effective layer. It also catches distributed attacks that per-server limits handle poorly, since the edge sees all the traffic in one place.

Frequently Asked Questions

How many login attempts should WordPress allow?

By default WordPress allows unlimited attempts, which is the problem. A sensible limit is a handful, around five, before a short lockout, whether set by a plugin or at the server. On our test site an unprotected login accepted 200 wrong guesses in 18 seconds; a server rate limit cut the same run off after about 21. The exact number matters less than having one at all.

Can I limit WordPress login attempts without a plugin?

Yes, if you have server access. Rate-limit the login page in nginx or Apache so bad requests are rejected before WordPress loads, which protects the server’s resources as well as the account. Our tested nginx example counts only POST requests so real visitors aren’t blocked. A plugin is the fallback when you can’t touch the server, but it runs inside PHP and uses more resources under attack.

Does limiting login attempts stop XML-RPC attacks?

Not on its own. XML-RPC is a separate door: its system.multicall method packs many password guesses into one request, so a limit that counts requests sees 100 guesses as one. On our test site a single multicall carried 100 login attempts. Rate-limit or disable xmlrpc.php alongside the login page, or the limit is easy to bypass.

Will rate limiting lock me out of my own site?

Only briefly, and only if you trip the limit yourself. A well-set rate limit targets rapid repeated attempts, not a normal login, and our tested config counts only POST attempts so loading the login form is never blocked. After an attack on the test site, a legitimate login succeeded seconds later. Keep the threshold above normal use and the real risk is minimal.

What to actually do

Stack the layers you can reach, starting with the cheapest and most effective:

  1. Everyone: use a strong, unique admin password and turn on two-factor authentication, so a guessed password still isn’t enough. That is the real backstop; the setup is in setting up two-factor authentication.
  2. Everyone: disable or restrict XML-RPC so the login limit can’t be bypassed (our disabling XML-RPC guide).
  3. Server access: rate-limit wp-login.php and xmlrpc.php at nginx or Apache (Layer 2). Add fail2ban (Layer 3) to ban repeat offenders.
  4. No server access: a login-limit plugin (Layer 1), and lean on 2FA.
  5. On a CDN or WAF: add a login-path rule at the edge (Layer 4); it is the most effective single step.

Rate limiting answers the volume of guesses; two-factor authentication answers the one guess that happens to be right. Use both. For where this sits in a full hardening pass, see the full WordPress security checklist.