Some days your WordPress site is fine. Then one morning your CPU is pegged, PHP-FPM looks like it’s doing CrossFit, and your logs are an endless scroll of POST /wp-login.php. Real users can’t log in. Your cache hit-rate craters. The attack isn’t “advanced” so much as “relentless.”
The tricky part isn’t blocking brute force. It’s blocking brute force without turning your own login into a self-inflicted denial of service. Hardening WordPress login is easy to do badly and surprisingly boring to do correctly. Let’s do it correctly.
Interesting facts and history (the why behind the pain)
- wp-login.php became a hot target because it’s predictable. Attackers love stable URLs; WordPress made admin access simple and therefore enumerable.
- XML-RPC (xmlrpc.php) was introduced to enable remote publishing. It also gave attackers a method to batch authentication attempts with
system.multicall. - “Admin” as a username used to be common. Older WordPress installs defaulted to it, and old habits die hard—especially in forgotten staging sites.
- Brute force is often a side quest. Many campaigns aren’t trying to break your site specifically; they’re sweeping the internet for weak passwords to build botnets.
- Credential stuffing outruns brute force. Attackers frequently use leaked username/password pairs; your “strong password policy” doesn’t help if users reuse passwords elsewhere.
- Rate limiting is older than most web frameworks. Network engineers were throttling abusive clients long before “WAF” became a product category.
- Lockouts can become a denial of service. Naive “block after 3 tries for 24 hours” can let attackers lock out real users by attempting their usernames.
- 403 vs 404 matters operationally. Returning 404 for
/wp-login.phpto everyone except allowlisted sources reduces scanning noise and makes logs easier to reason about. - Most WordPress compromises aren’t from login brute force. Plugins and themes with vulnerabilities are a larger source of real-world compromise; login hardening is still necessary, just not sufficient.
Joke #1: Brute force attackers are like toddlers at a locked door—endlessly optimistic, loud, and convinced the problem is you.
What brute force looks like in production
Signals you’ll see first
On a small VPS, brute force shows up as load average spikes and saturated PHP workers. On a larger host, it’s more subtle: increased 499/504s, elevated TTFB, and login endpoint latency. The user-facing symptom is usually “I can’t log in” or “The site is slow,” but your real problem is that every login attempt triggers expensive code paths: password hashing, session setup, database hits, maybe some plugin hooks that shouldn’t be on the request path.
Where attackers hit
- /wp-login.php — classic form login.
- /wp-admin/ — redirects to login; still generates work.
- /xmlrpc.php — remote publishing endpoint, often abused for password guessing.
- REST endpoints that leak usernames — not an auth bypass, but it helps enumerate.
Why “just block the IPs” fails
Attacks are distributed. IPs rotate. Some come from residential networks. Some come from cloud providers. If your defense strategy requires manual IP blocks, you’re not defending—you’re doing log archaeology.
Your real objective
You’re trying to reduce authentication attempts to a low, predictable rate while guaranteeing you retain at least two independent ways to regain admin access. That second part is where most people faceplant.
One operational quote worth keeping on a sticky note: “Hope is not a strategy.” — Gene Kranz
Fast diagnosis playbook (first/second/third)
This is the “you’re on call, it’s 02:00, and someone in marketing is awake” version.
First: confirm it’s brute force and identify the entry point
- Check access logs for top paths and request rates.
- Confirm whether it’s
wp-login.php,xmlrpc.php, or both. - Look for a tight loop: same URI, same method, lots of 200/302/403.
Second: protect capacity before you chase elegance
- Add a temporary rate limit at the edge (CDN/WAF) or at Nginx/Apache.
- If needed, block
xmlrpc.phptemporarily and validate nothing critical breaks. - Increase logging clarity: separate auth endpoints into their own log format or file.
Third: implement durable controls with a recovery path
- Allowlist admin IPs (or better: require VPN / zero-trust access) for
/wp-admin/and/wp-login.php. - Enable 2FA for admin accounts.
- Install Fail2ban (or equivalent) driven by web logs.
- Disable or restrict XML-RPC and confirm Jetpack/mobile workflows if you use them.
The bottleneck is usually not “CPU is slow.” It’s “your auth path is unthrottled.” Fix that first; then optimize.
Hands-on tasks: commands, outputs, and decisions
These tasks assume Linux with either Nginx or Apache, plus systemd. Run them on the web host (or on the reverse proxy if you have one). Every task includes: command, example output, what it means, and the decision you make.
Task 1: Identify top request paths fast
cr0x@server:~$ sudo awk '{print $7}' /var/log/nginx/access.log | sort | uniq -c | sort -nr | head
48219 /wp-login.php
11703 /xmlrpc.php
4122 /
3088 /wp-admin/
944 /wp-json/wp/v2/users
What the output means: /wp-login.php dominates. /xmlrpc.php is also getting hammered. The REST users endpoint suggests username enumeration.
Decision: Prioritize rate limiting and access control on wp-login.php and xmlrpc.php. Consider restricting REST user exposure.
Task 2: Measure request rate to wp-login.php
cr0x@server:~$ sudo awk '$7=="/wp-login.php"{print $4}' /var/log/nginx/access.log | cut -d: -f1-3 | sort | uniq -c | sort -nr | head
982 [27/Dec/2025:01
944 [27/Dec/2025:00
901 [26/Dec/2025:23
What it means: Roughly 900–980 login hits per hour (or per minute, depending on your log format granularity). Either way: too many for a real site.
Decision: Set an initial cap (example: 5 requests/minute per IP to wp-login.php) and observe collateral damage.
Task 3: Find the top offending IPs
cr0x@server:~$ sudo awk '$7=="/wp-login.php"{print $1}' /var/log/nginx/access.log | sort | uniq -c | sort -nr | head
710 203.0.113.44
688 198.51.100.72
621 192.0.2.19
What it means: A few IPs are loud. Many attacks won’t be this concentrated, but when it is, you can buy time with temporary blocks.
Decision: Use a short-lived firewall block only as a stopgap. Don’t build a process around manual blocking.
Task 4: Check whether XML-RPC is being abused via multicall
cr0x@server:~$ sudo grep -c "POST /xmlrpc.php" /var/log/nginx/access.log
11703
What it means: The endpoint is active. To confirm multicall, you’d inspect request bodies at the WAF/proxy or enable limited request-body logging (carefully; it can leak credentials).
Decision: If you don’t explicitly need XML-RPC, disable it. If you do, restrict it hard.
Task 5: Look for 401/403/200 mix to understand what’s happening
cr0x@server:~$ sudo awk '$7=="/wp-login.php"{print $9}' /var/log/nginx/access.log | sort | uniq -c | sort -nr
31001 200
14112 302
1106 403
What it means: Many login page loads return 200. 302s indicate redirects (often successful login redirects, but also could be redirects back to login on failure depending on setup).
Decision: Don’t guess. Use application logs or WordPress auth logs (plugin) if you need precise “failed password” counts; otherwise rate limit regardless.
Task 6: Confirm PHP-FPM pressure (a common bottleneck)
cr0x@server:~$ sudo systemctl status php8.2-fpm --no-pager
● php8.2-fpm.service - The PHP 8.2 FastCGI Process Manager
Loaded: loaded (/lib/systemd/system/php8.2-fpm.service; enabled; vendor preset: enabled)
Active: active (running) since Fri 2025-12-26 23:12:08 UTC; 2h 11min ago
Main PID: 1240 (php-fpm8.2)
Status: "Processes active: 43, idle: 2, Requests: 184201, slow: 57, Traffic: 1.2req/sec"
What it means: You’re near max active workers; slow requests exist. Brute force can exhaust workers and starve real traffic.
Decision: Rate-limit auth endpoints immediately. Only then consider tuning FPM.
Task 7: Inspect Nginx error logs for upstream timeouts
cr0x@server:~$ sudo tail -n 8 /var/log/nginx/error.log
2025/12/27 01:18:21 [error] 2011#2011: *9921 upstream timed out (110: Connection timed out) while reading response header from upstream, client: 198.51.100.72, server: example.com, request: "POST /wp-login.php HTTP/1.1", upstream: "fastcgi://unix:/run/php/php8.2-fpm.sock", host: "example.com"
What it means: Authentication path is slow enough to hit timeouts—classic under brute force. Also, timeouts create retries, which makes load worse.
Decision: Throttle, then reduce work in the auth path (plugins, external calls), then consider timeout tuning.
Task 8: Add a surgical Nginx rate limit for wp-login.php
First, check your Nginx config include layout. Then implement something minimal and reversible.
cr0x@server:~$ sudo nginx -T 2>/dev/null | grep -n "http {" -n | head
32:http {
What it means: You know where the http context exists; rate-limit zones must be defined there.
Decision: Add a limit_req_zone under http, then apply in the location for wp-login.php.
cr0x@server:~$ sudo bash -lc 'cat >/etc/nginx/conf.d/wp-login-rate-limit.conf <<"EOF"
limit_req_zone $binary_remote_addr zone=wp_login:10m rate=5r/m;
EOF'
cr0x@server:~$ sudo bash -lc 'cat >/etc/nginx/snippets/wp-login-protect.conf <<"EOF"
location = /wp-login.php {
limit_req zone=wp_login burst=10 nodelay;
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php8.2-fpm.sock;
}
EOF'
cr0x@server:~$ sudo nginx -t
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
What it means: Config parses. You haven’t proven behavior yet, but you’re safe to reload.
Decision: Reload and watch for 429 responses and user complaints.
cr0x@server:~$ sudo systemctl reload nginx
Task 9: Verify rate limiting is actually happening
cr0x@server:~$ sudo grep -E "limiting requests" /var/log/nginx/error.log | tail -n 3
2025/12/27 01:22:09 [error] 2011#2011: *10441 limiting requests, excess: 5.900 by zone "wp_login", client: 203.0.113.44, server: example.com, request: "POST /wp-login.php HTTP/1.1", host: "example.com"
What it means: Nginx is actively throttling. That’s capacity protection.
Decision: Keep it, but don’t stop here—rate limiting alone won’t prevent slow credential stuffing forever.
Task 10: Add a safe allowlist gate for wp-admin (without breaking the front-end)
This pattern is blunt and effective: allow admin access only from a VPN egress IP or corporate NAT. It’s not friendly for roaming users, but it’s operationally sane for businesses.
cr0x@server:~$ sudo bash -lc 'cat >/etc/nginx/snippets/wp-admin-allowlist.conf <<"EOF"
location ^~ /wp-admin/ {
allow 203.0.113.10;
allow 203.0.113.11;
deny all;
try_files $uri $uri/ /index.php?$args;
}
EOF'
cr0x@server:~$ sudo nginx -t
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
What it means: Syntax ok. Remember /wp-admin/admin-ajax.php is used by front-end themes/plugins.
Decision: Explicitly exempt admin-ajax.php from the allowlist if your site uses it publicly.
cr0x@server:~$ sudo bash -lc 'cat >/etc/nginx/snippets/wp-admin-ajax-open.conf <<"EOF"
location = /wp-admin/admin-ajax.php {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php8.2-fpm.sock;
}
EOF'
Task 11: Disable XML-RPC at the web layer (fast) and confirm impact
cr0x@server:~$ sudo bash -lc 'cat >/etc/nginx/snippets/disable-xmlrpc.conf <<"EOF"
location = /xmlrpc.php {
return 403;
}
EOF'
cr0x@server:~$ sudo nginx -t
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
What it means: This blocks XML-RPC completely. Jetpack, some mobile apps, and some integrations may depend on it.
Decision: If you need XML-RPC, don’t disable—restrict to known IPs or require additional checks. Otherwise, keep it off.
Task 12: Install and validate Fail2ban against wp-login patterns
cr0x@server:~$ sudo apt-get update -y && sudo apt-get install -y fail2ban
Reading package lists... Done
Building dependency tree... Done
fail2ban is already the newest version (1.0.2-2).
What it means: Fail2ban is available. Now you need a filter and a jail tuned to your log format.
Decision: Use a low ban time and a reasonable findtime; you’re rate limiting already, this is for repeat offenders and distributed noise reduction.
cr0x@server:~$ sudo bash -lc 'cat >/etc/fail2ban/filter.d/nginx-wp-login.conf <<"EOF"
[Definition]
failregex = ^<HOST> - .* "POST /wp-login\.php HTTP/1\.[01]" (200|302) .*
ignoreregex =
EOF'
cr0x@server:~$ sudo bash -lc 'cat >/etc/fail2ban/jail.d/nginx-wp-login.local <<"EOF"
[nginx-wp-login]
enabled = true
port = http,https
filter = nginx-wp-login
logpath = /var/log/nginx/access.log
maxretry = 10
findtime = 600
bantime = 3600
EOF'
cr0x@server:~$ sudo fail2ban-client reload
OK
cr0x@server:~$ sudo fail2ban-client status nginx-wp-login
Status for the jail: nginx-wp-login
|- Filter
| |- Currently failed: 2
| |- Total failed: 118
| `- File list: /var/log/nginx/access.log
`- Actions
|- Currently banned: 3
|- Total banned: 7
`- Banned IP list: 198.51.100.72 203.0.113.44 192.0.2.19
What it means: You have automated bans. This is not “security solved,” but it meaningfully reduces noise.
Decision: Keep bans moderate; don’t accidentally ban your office NAT for an hour because someone forgot their password.
Task 13: Confirm WordPress users and remove obvious risk
cr0x@server:~$ cd /var/www/html && sudo -u www-data wp user list --fields=ID,user_login,roles
+----+------------+----------------------+
| ID | user_login | roles |
+----+------------+----------------------+
| 1 | admin | administrator |
| 12 | editor1 | editor |
| 17 | seo-team | administrator |
+----+------------+----------------------+
What it means: If you still have admin as a login, you’re making attackers’ lives easier.
Decision: Create a new admin user with a non-obvious login, migrate privileges, and delete or demote admin. (Also: enable 2FA.)
Task 14: Emergency recovery — reset an admin password safely
cr0x@server:~$ cd /var/www/html && sudo -u www-data wp user update admin --user_pass='REDACTED-strong-password'
Success: Updated user 1.
What it means: You can regain access without touching phpMyAdmin or manually editing the database.
Decision: If you don’t have WP-CLI installed, install it now—before you need it at 02:00.
Task 15: Verify you didn’t lock yourself out (the boring, necessary test)
cr0x@server:~$ curl -I -s https://example.com/wp-login.php | head -n 5
HTTP/2 200
date: Sat, 27 Dec 2025 01:28:11 GMT
content-type: text/html; charset=UTF-8
cache-control: no-store, no-cache, must-revalidate, max-age=0
What it means: The endpoint is reachable from where you’re testing. If you applied allowlists, test from an allowed network as well.
Decision: Run a second test from a non-allowed IP (or use a coworker’s LTE) to confirm you get the intended deny behavior.
Hardening patterns that don’t lock you out
1) Prefer “gate” controls over “guessing” controls
Rate limiting and Fail2ban react to behavior. Allowlists and VPN requirements prevent exposure entirely. If this is a corporate WordPress admin used by employees, the right move is simple: do not expose wp-admin to the open internet. Put it behind VPN, identity-aware proxy, or at least an IP allowlist.
If you run a community site with many admins roaming on mobile, you can’t rely on allowlisting. Then you lean harder on: 2FA, passwordless (where available), and strong rate limiting.
2) Make wp-login cheap to hit
Even with rate limits, you want each request to be as low-cost as practical.
- Keep login path free of heavy plugins that hook into authentication and call external APIs.
- Ensure object caching is working to reduce DB churn on repeated requests.
- Disable user enumeration endpoints (where practical) so attackers can’t cheaply confirm usernames.
3) Decide what to do about XML-RPC, explicitly
XML-RPC is not “evil.” It’s just old and frequently unnecessary. If you don’t use Jetpack, the WordPress mobile app, or legacy integrations, block it.
If you do need it, restrict it:
- Allowlist only known IPs (if your integration has stable egress).
- Rate limit it separately from
wp-login.php. - Prefer application-level controls that disable
system.multicallabuse (some security plugins do this).
4) Use 2FA like you mean it
2FA doesn’t stop brute force attempts; it makes successful brute force much less likely. The operational payoff is huge: you can be slightly less aggressive with lockouts without increasing risk.
Two operational rules:
- Require 2FA for administrator accounts at minimum.
- Keep backup codes in your password manager accessible to the on-call rotation.
5) Don’t “hide” wp-login as your primary defense
Changing the login URL can reduce noise. It does not fix the underlying problem, and it can break integrations, caches, and operational runbooks. Use it only as a secondary measure, and only if you have a solid recovery plan.
6) Give yourself at least two recovery paths
Lockouts are survivable if you planned for them. Pick two (or more) of these and test them quarterly:
- WP-CLI access as
www-data(or the correct FPM user) to reset passwords and manage plugins. - Emergency “break-glass” VPN profile stored in a secure vault.
- A bastion host with stable IP allowlisted for admin endpoints.
- Database access procedure to create an admin user (last resort; dangerous in a hurry).
Joke #2: The only thing easier than locking out attackers is locking out your CEO five minutes before a board update.
Three corporate mini-stories (how people actually fail)
Mini-story 1: The incident caused by a wrong assumption
Company A had a WordPress install that “wasn’t important.” It hosted a careers blog and a handful of landing pages. The main product was elsewhere, so the WordPress host lived in a neglected corner of the infrastructure.
An engineer noticed brute force attempts and added an aggressive rule: block any IP that hits /wp-login.php more than three times in a day. It felt safe; “real users don’t fail passwords that much,” they reasoned. The wrong assumption wasn’t technical, it was human. Real users absolutely fail passwords that much, especially after an SSO migration, a password manager change, or a long vacation.
The attacker didn’t need to guess any passwords. They just needed a list of usernames. They attempted three logins for each known admin username from a rotating set of IPs. The security plugin dutifully locked every admin account for 24 hours. Suddenly no one could publish job posts, and recruiters started emailing screenshots of “account locked” banners like it was a product bug.
Fixing it required a rollback of the lockout policy, manual unlocks, and a new design: rate limiting per IP (not per username), modest ban windows, 2FA, and admin access gated behind a VPN. The important lesson was simple: brute force defense should punish bots, not humans.
Mini-story 2: The optimization that backfired
Company B ran WordPress behind a reverse proxy and wanted to cut backend load. Someone enabled full-page caching aggressively and tried to “cache everything.” The site got faster—until the next brute force wave.
The caching layer didn’t cache wp-login.php (good), but it did cache some redirect and error pages in an odd way. Under load, clients started receiving inconsistent redirects between /wp-admin/ and /wp-login.php. Some users got stuck in loops. Meanwhile, the proxy’s cache keys didn’t include a header that distinguished certain auth-related responses. The proxy happily served the wrong thing quickly.
It got worse: the proxy had been configured to retry upstream errors automatically. When PHP-FPM began timing out under brute force, the proxy retried. That doubled the traffic on the hottest endpoint. Everyone meant well; the system did not care.
The fix was “less clever, more correct”: explicitly bypass caching for all auth paths, disable automatic retries for non-idempotent requests like POST to login, and implement rate limits at the proxy. Performance returned, and so did predictable behavior. The moral: caching is not a security control, and retries are not free.
Mini-story 3: The boring but correct practice that saved the day
Company C had a mundane habit: every infrastructure change to WordPress included a tested recovery checklist. Not a wiki page that nobody reads—an actual runbook with a dry-run every quarter. Two admins would practice: “simulate lockout,” “regain access via WP-CLI,” “validate 2FA backups,” “confirm allowlist works,” “confirm non-admin traffic unaffected.”
When a new WAF rule was rolled out globally, it started challenging logins with a bot check that didn’t play nicely with certain corporate networks. Users complained that login “spins forever.” The WAF team initially suspected the WordPress host, because that’s how these things usually go.
The on-call engineer followed the runbook. First, validate backend health. Second, check auth endpoint status codes. Third, compare behavior from an allowed bastion and from a normal client. The diff pointed straight to the WAF. They rolled back the specific rule for wp-login.php while keeping rate limits and bot protections for the rest of the site.
No heroics. No guessing. Just a practiced recovery path and controlled scope. The “boring” part—routine drills—was exactly why nobody spent the night debating firewall rules in Slack.
Common mistakes: symptoms → root cause → fix
1) Symptom: Admins randomly can’t log in; support tickets spike
Root cause: Lockout by username (or overly strict global lockouts) lets attackers lock out legitimate accounts without knowing passwords.
Fix: Rate limit per IP and per endpoint, not per username. Keep bans shorter. Require 2FA for admin. Use an allowlisted admin access path (VPN/bastion).
2) Symptom: Site is slow; PHP-FPM max children reached
Root cause: Unthrottled login attempts exhaust PHP workers; expensive auth hooks amplify the cost.
Fix: Add web-layer rate limiting (limit_req or equivalent), then audit plugins for auth hooks and remove heavy ones from the login path.
3) Symptom: Blocking wp-admin breaks front-end features
Root cause: /wp-admin/admin-ajax.php is used by public site components; blocking all of /wp-admin/ breaks AJAX features.
Fix: Allow public access to admin-ajax.php (and any other required endpoints) while restricting the rest of /wp-admin/.
4) Symptom: You disabled XML-RPC and something “mysteriously” stopped working
Root cause: Jetpack, mobile apps, or legacy integration depended on XML-RPC.
Fix: Either re-enable with IP allowlisting/rate limiting or replace the integration with REST-based alternatives. Make XML-RPC a conscious choice.
5) Symptom: You added a WAF challenge and logins loop or fail silently
Root cause: Bot challenges can break POST flows, cookies, or non-browser clients; sometimes corporate proxies strip headers.
Fix: Exempt /wp-login.php from interactive challenges; use rate limits and reputation rules instead. Test from multiple networks.
6) Symptom: Fail2ban bans everyone behind a NAT
Root cause: Many users share one public IP; maxretry triggers on aggregate.
Fix: Increase thresholds, shorten bans, and rely more on rate limiting. Consider gating admin behind VPN to reduce shared-IP collisions.
7) Symptom: You “hid” wp-login and now integrations fail and nobody remembers the URL
Root cause: Security-through-obscurity without operational hygiene.
Fix: If you must change login URL, document it in an internal runbook and keep a break-glass path (WP-CLI + VPN/bastion). Treat it as an optional noise reducer, not a defense.
Checklists / step-by-step plan
Phase 0 (today): stop the bleeding without breaking things
- Confirm target endpoints in logs:
wp-login.php,xmlrpc.php,wp-admin. - Add web-layer rate limiting to
wp-login.php(andxmlrpc.phpif enabled). - Disable XML-RPC if you don’t need it. If you do, restrict it rather than leaving it open.
- Check PHP-FPM saturation and ensure timeouts aren’t cascading into retries.
- Verify real admin login still works from at least two networks.
Phase 1 (this week): reduce exposure and add identity controls
- Put wp-admin behind a gate: VPN, identity-aware proxy, or IP allowlisting.
- Require 2FA for admins and store backup codes in a shared, access-controlled vault.
- Remove obvious accounts: kill
admin, audit dormant admin users, enforce least privilege. - Deploy Fail2ban tuned to your log format and NAT realities.
- Add monitoring for spikes in auth endpoint requests, 429s, and FPM worker exhaustion.
Phase 2 (this month): make it resilient and testable
- Create a break-glass runbook: WP-CLI password reset, plugin disable, WAF rollback steps.
- Run a lockout drill: intentionally simulate an allowlist failure and recover.
- Review plugin/theme attack surface; update, remove abandonware, and reduce auth hooks.
- Consider moving auth endpoints behind separate protection (edge rules, stricter rate limits, dedicated logging).
FAQ
1) Should I just block wp-login.php entirely?
If you have a controlled admin path (VPN/bastion/identity proxy), yes—block it for the public internet and allow only the gate. If you have roaming admins, don’t fully block; rate limit + 2FA + Fail2ban.
2) Is changing the login URL worth it?
It reduces noise, not risk. Use it only after you have rate limiting, 2FA, and a tested recovery plan. Otherwise you’re trading one problem for a new “where’s the login” incident.
3) Why do I see brute force even on a small, obscure site?
Because it’s automated scanning. Attackers don’t need to know you exist; they only need to know WordPress exists. Predictable endpoints are their favorite kind.
4) Will 2FA stop brute force attempts?
No. It stops many successful logins. You still need rate limiting to protect capacity and keep auth from becoming a denial of service.
5) Can I disable XML-RPC safely?
Often yes. But check whether you use Jetpack, the WordPress mobile app, or any older integration. If you’re not sure, disable it during a low-traffic window and watch logs and business workflows.
6) Why does blocking /wp-admin/ sometimes break the site homepage?
It usually breaks features that call /wp-admin/admin-ajax.php from the front-end. Exempt that endpoint (or migrate away from admin-ajax usage if possible).
7) Is Fail2ban enough by itself?
No. It’s a good second line. The first line is web-layer rate limiting and reducing exposure. Fail2ban also struggles with highly distributed attacks and shared-IP NAT environments.
8) How do I avoid locking myself out when adding allowlists?
Always keep a second access method: a bastion with a stable IP, or VPN. Validate from both allowed and non-allowed networks before you walk away. And keep WP-CLI ready to reset credentials.
9) What’s a sane rate limit for wp-login.php?
Start around 5 requests/minute per IP with a small burst, then tune. If you have many legitimate users behind a NAT, increase the burst or rate and rely more on 2FA and a WAF reputation layer.
10) What if the attacker uses valid credentials (credential stuffing)?
Rate limiting reduces speed, 2FA prevents most account takeovers, and monitoring (failed logins, unusual geos, impossible travel) catches what gets through. Also: enforce password manager use and remove dormant accounts.
Next steps you can do today
Do three things and your WordPress login stops being a punching bag:
- Rate limit auth endpoints at the web layer. It protects capacity immediately.
- Gate wp-admin behind a VPN/bastion/identity proxy if this is a business admin, not a public community admin.
- Keep recovery paths real: WP-CLI access, 2FA backup codes, and a tested runbook. “We can always SSH in” is not a recovery plan; it’s a bedtime story.
Then take the unglamorous win: audit admin accounts, remove the obvious usernames, disable what you don’t use (especially XML-RPC), and monitor login request rates like you monitor disk space. Not because it’s exciting. Because it works.