Check your access log for a minute and you'll probably see it: dozens or hundreds of POST requests to /wp-login.php, all from different IPs, all trying random username/password combos. Most site owners never notice until either the server slows to a crawl during an attack wave, or worse, one of those guesses lands. This isn't a targeted hack — it's automated, it never stops, and it happens to sites with ten visitors a month just as often as busy stores. Here's how to actually shut it down instead of just hoping your password holds.
Symptom: What a Brute-Force Wave Looks Like
A few tells that you're being hit, not just getting normal traffic:
- Your hosting CPU usage graph spikes with no matching increase in real visitors.
wp-login.phpshows up hundreds of times inaccess_login a short window, often from a rotating list of IPs.- A security plugin (Wordfence, iThemes Security, Sucuri) emails you "blocked login attempt" alerts constantly.
- MySQL's
wp_optionstable has a bloated_transient_row count from failed-login tracking, and admin pages feel sluggish. - In cPanel, WHM's cPHulk log (if you're also getting this on cPanel itself) shows repeated failures — but note cPHulk only protects the cPanel login, not WordPress's own
wp-login.php. They're separate doors.
Cause: Why wp-login.php Is Such an Easy Target
WordPress ships with no rate limiting on login attempts out of the box. wp-login.php returns a clear, distinguishable error for "wrong password" versus "user doesn't exist," which makes username enumeration trivial. Add in xmlrpc.php, which lets an attacker test hundreds of password combinations in a single HTTP request via system.multicall, and you've got a door that's not just unlocked but actively inviting a crowbar.
None of this needs your site to be famous or valuable. Botnets scan the entire IPv4 range for anything answering on port 80/443 with a WordPress signature, then queue it up for the same script every other WordPress site gets.
Fix 1: Rate-Limit at the Web Server Level
This is the highest-leverage fix because it stops requests before PHP or MySQL ever gets involved — the attack traffic doesn't touch your app at all.
On Nginx (VPS with Nginx or Nginx + PHP-FPM)
Add a rate-limit zone in the http {} block of your main Nginx config, then apply it to the login endpoint:
limit_req_zone $binary_remote_addr zone=wplogin:10m rate=2r/m;
server {
location = /wp-login.php {
limit_req zone=wplogin burst=3 nodelay;
include fastcgi_params;
fastcgi_pass unix:/run/php/php8.2-fpm.sock;
}
}
Reload with nginx -t && systemctl reload nginx. Two requests a minute per IP is plenty for a real human logging in and painfully slow for a script trying thousands of combinations.
On Apache/LiteSpeed (typical cPanel shared or VPS hosting)
Without Nginx in front, you're better off blocking outright rather than throttling — use .htaccess to restrict wp-login.php to your own IP or a small allowlist if you have a static IP:
<Files wp-login.php>
Order Deny,Allow
Deny from all
Allow from 203.0.113.10
</Files>
If your IP changes often, skip this and rely on Fix 2 and Fix 3 instead — a hard IP lock will lock you out the next time your ISP reassigns an address.
Fix 2: Fail2Ban Jail for wp-login (Root/VPS Access)
If you manage the server directly — not typical on shared cPanel hosting, but standard on a SkyServer VPS — Fail2Ban is the cleanest permanent fix. It watches your web server log for failed login patterns and firewalls the offending IP automatically, no plugin overhead inside WordPress at all.
First, make sure failed logins are actually distinguishable in your access log. Add a filter at /etc/fail2ban/filter.d/wordpress.conf:
[Definition]
failregex = ^<HOST> -.*"POST /wp-login.php
ignoreregex =
Then create the jail at /etc/fail2ban/jail.d/wordpress.conf:
[wordpress]
enabled = true
filter = wordpress
logpath = /var/log/nginx/access.log
maxretry = 5
findtime = 600
bantime = 3600
action = iptables-multiport[name=wordpress, port="http,https"]
Restart the service and confirm it picked up the jail:
systemctl restart fail2ban
fail2ban-client status wordpress
This regex bans on any POST to wp-login, which is intentionally aggressive — if you want to only ban actual failed credentials, a WordPress security plugin that logs failures to a separate file (Wordfence does this) gives Fail2Ban a cleaner signal to match against instead.
Fix 3: Kill xmlrpc.php Unless You Need It
Unless you're using the Jetpack mobile app, a legacy XML-RPC based publishing tool, or pingbacks/trackbacks, you almost certainly don't need xmlrpc.php at all. Block it in .htaccess:
<Files xmlrpc.php>
Order Deny,Allow
Deny from all
</Files>
Or on Nginx:
location = /xmlrpc.php {
deny all;
}
This alone closes off the system.multicall mass password-testing trick, which is often the actual method behind a login spike even when your logs show the noise concentrated on wp-login.php.
Fix 4: A Login-Limiting Plugin as a Safety Net
Server-level fixes are the real defense, but a plugin adds a second layer and gives you visibility without SSH access — useful if you're on shared cPanel hosting without root. Limit Login Attempts Reloaded or Wordfence both do the job: lock an IP out after N failed attempts, extend the lockout on repeat offenders, and log the activity.
Two settings matter more than the defaults:
- Drop the allowed attempts to 3-4 before a lockout, not the default 20.
- Turn on the "lockout notification" email sparingly — during an active brute-force wave it'll flood your inbox otherwise.
Also rename your login URL with a plugin like WPS Hide Login if the attack volume stays high after the above — it won't stop a targeted attacker, but it removes you from the pool of sites a generic wp-login.php scanner even bothers hitting.
Fix 5: Use Cloudflare's Free WAF Rule
If your domain is proxied through Cloudflare (orange-clouded), add a rate-limiting rule under Security → WAF → Rate limiting rules: match the URI path /wp-login.php, threshold of 5 requests per minute per IP, and action "Block" for 1 hour. This stops the traffic at Cloudflare's edge, before it even reaches your server — the lightest possible load on your hosting.
Prevention: Keep It Closed
| Layer | What to do | Who this fits |
|---|---|---|
| Web server | Rate-limit or IP-restrict wp-login.php | VPS/Nginx or Apache with .htaccess access |
| Firewall | Fail2Ban jail watching the access log | Root/VPS access |
| WordPress | Disable xmlrpc.php, cap login attempts, enforce 2FA | Everyone |
| CDN/edge | Cloudflare rate-limiting rule on the login path | Sites proxied through Cloudflare |
Layer at least two of these — server-level plus WordPress-level — so a gap in one doesn't leave the door wide open. And keep an eye on your access logs for a week after making changes; a sudden silence on wp-login.php POST attempts is how you confirm the fix actually worked rather than just assuming it did.
Frequently Asked Questions
Does changing my admin username stop brute-force attacks?
It helps but doesn't stop them. Bots that guess both username and password will still hit your site just as often; you're only removing the "admin" and "your-domain-name" guesses from their easy wins. Combine it with rate limiting rather than relying on it alone.
Will blocking xmlrpc.php break anything on my site?
For most sites, no — it only affects the Jetpack mobile app, some older remote-publishing tools, and pingback/trackback notifications between blogs. If you use Jetpack's app to post from your phone, test after blocking, since some Jetpack features do rely on XML-RPC.
I'm on shared cPanel hosting with no root access — can I still use Fail2Ban?
No, Fail2Ban needs root to manage iptables, so it's VPS/dedicated-server only. On shared cPanel hosting, lean on a login-limiting plugin, the .htaccess IP restriction if you have a static IP, and a Cloudflare rate-limiting rule if your domain is proxied through it.
My server's CPU spikes during these attacks even with a login-limit plugin installed. Why?
A plugin still has to load WordPress core and query the database to check the lockout status on every request, so it reduces damage but doesn't eliminate load from a large enough attack. Rate limiting at Nginx or blocking at Cloudflare's edge is what actually removes the load from your server, since the request never reaches PHP.
How do I tell a real customer with a forgotten password from an attacker in my logs?
Volume and pattern. A real person fails a handful of times from one IP over a few minutes. An attack shows dozens to hundreds of attempts, often from many different IPs in a short window, frequently with usernames that don't match any real account on your site (like "administrator" or "root" on a site where neither exists).
