WordPress Security: The Hardening Checklist That Doesn’t Break Logins

Was this helpful?

Your WordPress site is “fine” until it’s 3:07 AM and the on-call phone vibrates like it’s trying to tunnel through the nightstand. The symptom is always the same: login failures, CPU pegged, database connections stacked up, and a line item in the incident timeline that reads “someone changed something security-related.”

Hardening isn’t the enemy. Bad hardening is. This is a production checklist for tightening WordPress security without turning legitimate editors, customers, and SSO users into collateral damage.

Threat model in one page: what you’re actually defending

WordPress hardening becomes sane when you stop treating it like a vibe and start treating it like a threat model. The “attackers” you’ll see most often aren’t movie hackers. They’re bots, commodity malware, and opportunists scraping the internet for known weak points. Your job is to make your site boring to attack and resilient when attacked.

What gets attacked most

  • /wp-login.php brute force (credential stuffing, password spraying, API token abuse).
  • /xmlrpc.php (historically used for pingbacks and remote publishing; often abused for amplification and brute force).
  • Plugin/theme vulnerabilities (RCE, arbitrary file upload, auth bypass, CSRF).
  • Writeable webroot (a single file upload bug becomes persistence and defacement).
  • Supply chain drift (plugins updated “whenever,” with no rollbacks, no canary, no staged changes).

What “doesn’t break logins” actually means

Login safety isn’t just “I can log in right now.” It includes:

  • Humans on dynamic IPs (home internet, travel, mobile hot-spot).
  • Legitimate automation: uptime probes, headless publishing, WooCommerce API clients, SSO callbacks, reverse proxy health checks.
  • Editor workflows across multiple roles: admins, authors, contributors.
  • Emergency access when your WAF or auth service is degraded.

Hardening should be reversible, observable, and layered. If a change can lock out your own staff, it must have a backdoor plan (preferably the kind you can execute at 3 AM with one eye open and no heroics).

One reliability quote worth taping to the monitor: “Hope is not a strategy.”Gordon R. Sullivan.

Interesting facts and short history (the stuff that explains today’s mess)

  1. WordPress started in 2003 as a fork of b2/cafelog; its plugin architecture helped it win, and also gave attackers a massive surface area.
  2. xmlrpc.php predates REST APIs; it enabled remote publishing long before modern authentication patterns were common in CMS land.
  3. Brute force moved from “targeted” to “ambient” once botnets and leaked credential dumps became cheap; you don’t get attacked because you’re special, you get attacked because you exist.
  4. WordPress core auto-updates (for minor releases) became a mainstream safety net over time, but plugins remain the dominant risk.
  5. “Security plugins” became a category largely because shared hosting made server-level controls hard for normal site owners to access.
  6. wp-config.php salts exist because cookies and sessions need per-site cryptographic uniqueness; old sites sometimes carry ancient salts across migrations.
  7. File permission guidance evolved because early WordPress installs commonly ran PHP as the same user that owned files, encouraging world-writable directories.
  8. CDNs changed the login story: rate limiting and bot mitigation moved outward, but origin misconfig still leaks direct access to wp-login.

Hardening principles that prevent lockouts

1) Reduce attack surface before you crank up enforcement

Blocking the world from /wp-admin feels satisfying. It also breaks mobile editors, REST clients, and anyone whose IP changes. Instead, start by removing the things you don’t use (xmlrpc, unused plugins, old themes), then add authentication and throttling, then add “deny” rules where they’re safe.

2) Every security control needs observability

If you can’t answer “who got blocked?” and “why?” from logs, you’re not doing security. You’re doing ritual. Put the deny decisions in logs with enough fields to act on: IP, path, status, user agent, request id, and upstream response time.

3) Prefer rate limits and challenges over IP allowlists

Allowlists work for office networks and VPNs. They fail in remote/hybrid reality. Rate limiting + strong authentication buys you protection without brittle assumptions about IP stability.

4) Separate identity from authorization

Two-factor authentication (or SSO) strengthens identity. It doesn’t replace authorization. Keep roles minimal. Don’t give “Administrator” to solve workflow friction. That’s like adding more gasoline because the engine is making noise.

5) Keep the blast radius small

Run PHP-FPM with a dedicated user. Lock file ownership. If a plugin vulnerability hits, your goal is “limited damage” instead of “entire filesystem is a canvas.”

Joke #1: The only thing more persistent than WordPress malware is the person who insists the admin password should be “CompanyName2024!”.

Fast diagnosis playbook: find the bottleneck before you “fix” it

When login problems appear after “hardening,” treat it like an incident. Don’t guess. Don’t roll back blindly. Triage in this order because it narrows the search quickly and prevents you from blaming the wrong layer.

First: confirm what’s failing (browser vs server vs upstream)

  • Is /wp-login.php returning 200 with a form, or 403/429/503?
  • Is the failure only for some users (role-based, IP-based, geo-based)?
  • Is the origin being hit directly, bypassing CDN/WAF?

Second: check rate limiting / WAF / Fail2ban decisions

  • Look for 403/429 spikes on login endpoints.
  • Confirm whether your blocklists include your office/VPN egress or your monitoring IPs.
  • Validate that a “challenge” isn’t breaking non-browser clients (REST calls, mobile apps).

Third: check app and database performance

  • Login can be slow because the database is slow (options table bloat, autoloaded options, missing indexes).
  • Or because PHP workers are saturated (too few PHP-FPM children, too many slow plugins).
  • Or because disk is sad (IO wait spikes; logs and uploads on the same volume).

Fourth: check correctness controls

  • Permissions/ownership preventing session writes, uploads, plugin updates.
  • Object cache misconfiguration causing auth cookie loops.
  • Security headers or cookie flags mis-set across HTTP/HTTPS boundaries.

If you can’t identify the bottleneck in 10–15 minutes, freeze changes, snapshot the state (configs + logs), and then proceed with controlled rollback. Adrenaline is a terrible change-management system.

Practical hardening tasks (commands, outputs, decisions)

These are real operational tasks you can run on a typical Linux host (Debian/Ubuntu flavor). Each task includes: the command, what “good” output looks like, and what decision to make.

Task 1: Identify who is actually serving WordPress (Nginx/Apache, PHP-FPM user)

cr0x@server:~$ ps -eo user,comm,args | egrep 'nginx:|apache2|php-fpm' | head
root     nginx:  nginx: master process /usr/sbin/nginx -g daemon on; master_process on;
www-data nginx:  nginx: worker process
www-data php-fpm8.2 php-fpm: pool www
www-data php-fpm8.2 php-fpm: pool www

What it means: Nginx workers and PHP-FPM run as www-data. That informs file ownership and permission choices.

Decision: Standardize on a dedicated service user (www-data is fine) and ensure WordPress files are owned by an admin-controlled user group, not writable by the web user unless you explicitly want in-place updates.

Task 2: Confirm WordPress path and detect accidental multiple installs

cr0x@server:~$ sudo find /var/www -maxdepth 3 -name wp-config.php -print
/var/www/site1/wp-config.php

What it means: Only one wp-config.php found under expected web roots. Multiple configs often means forgotten staging sites exposed to the internet.

Decision: If you find extra installs, either remove them, firewall them, or password-protect them. Hidden staging sites are where attackers go to shop.

Task 3: Check file ownership and writable surfaces

cr0x@server:~$ cd /var/www/site1
cr0x@server:~$ sudo find . -maxdepth 2 -type d -perm -0002 -print | head
./wp-content/uploads

What it means: Directories writable by “others” are red flags. WordPress needs uploads writable, but not necessarily world-writable.

Decision: Remove world-writable bits. Keep only what must be writable by the PHP user (usually wp-content/uploads and maybe cache dirs).

Task 4: Apply sane permissions (without breaking media uploads)

cr0x@server:~$ sudo chown -R root:www-data /var/www/site1
cr0x@server:~$ sudo find /var/www/site1 -type d -exec chmod 0750 {} \;
cr0x@server:~$ sudo find /var/www/site1 -type f -exec chmod 0640 {} \;
cr0x@server:~$ sudo chown -R www-data:www-data /var/www/site1/wp-content/uploads
cr0x@server:~$ sudo find /var/www/site1/wp-content/uploads -type d -exec chmod 0750 {} \;
cr0x@server:~$ sudo find /var/www/site1/wp-content/uploads -type f -exec chmod 0640 {} \;

What it means: Core files are not writable by the runtime user; uploads are writable. This blocks a large class of “write webshell into core” persistence.

Decision: If you rely on in-dashboard plugin/theme updates, either accept a controlled writable path (and strong monitoring), or switch to deployment-based updates (recommended in serious environments).

Task 5: Lock down wp-config.php specifically

cr0x@server:~$ sudo stat -c '%a %U:%G %n' /var/www/site1/wp-config.php
640 root:www-data /var/www/site1/wp-config.php

What it means: The web user (in group www-data) can read config, but not write it. That’s usually what you want.

Decision: If the file is writable by www-data, fix it. If the web server can’t read it, your site won’t boot. Don’t “solve” that with 777.

Task 6: Verify core and plugin integrity with WP-CLI

cr0x@server:~$ cd /var/www/site1
cr0x@server:~$ sudo -u www-data wp core verify-checksums
Success: WordPress installation verifies against checksums.

What it means: Core files match expected checksums. If this fails, you may have tampering, or you’re on a version/build that doesn’t match checksums.

Decision: Failures demand investigation. If you didn’t intentionally patch core, treat mismatch as suspicious and re-install core from trusted source.

Task 7: Enumerate plugins and kill what you don’t use

cr0x@server:~$ sudo -u www-data wp plugin list --status=inactive
+---------------------+----------+-----------+---------+
| name                | status   | update    | version |
+---------------------+----------+-----------+---------+
| hello-dolly         | inactive | none      | 1.7.2   |
| old-seo-plugin      | inactive | available | 2.1.0   |
+---------------------+----------+-----------+---------+

What it means: Inactive plugins are still code on disk. Vulnerabilities don’t care if your UI checkbox is off.

Decision: Uninstall inactive plugins unless you have a strong reason to keep them (and even then, keep a package artifact instead of live code).

Task 8: Update safely with a “what will change” preview

cr0x@server:~$ sudo -u www-data wp plugin update --all --dry-run
Available plugin updates:
- woocommerce (7.9.0 -> 7.9.2)
- wordfence (7.10.1 -> 7.10.2)
Success: Checked available updates.

What it means: You can see the blast radius before pushing changes. Dry runs are underused because people enjoy surprises, apparently.

Decision: Stage updates, especially on WooCommerce or membership sites. Roll forward with backups and a rollback plan.

Task 9: Detect if xmlrpc.php is still reachable (and decide what to do)

cr0x@server:~$ curl -s -o /dev/null -w '%{http_code}\n' https://example.com/xmlrpc.php
200

What it means: xmlrpc.php is reachable. That’s not automatically wrong, but it’s commonly abused.

Decision: If you don’t use Jetpack remote features, old mobile publishing, or pingbacks, block it. If you do use it, restrict methods or rate-limit aggressively.

Task 10: Block xmlrpc.php at the web server (Nginx) without touching wp-login

Example Nginx snippet (apply in the server block):

cr0x@server:~$ sudo nginx -T 2>/dev/null | sed -n '1,120p'
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 is valid. Now add an explicit location block (shown below) and re-test.

cr0x@server:~$ sudo tee /etc/nginx/snippets/wordpress-xmlrpc-block.conf >/dev/null <<'EOF'
location = /xmlrpc.php {
  deny all;
  access_log /var/log/nginx/xmlrpc-block.log;
  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
cr0x@server:~$ sudo systemctl reload nginx

Decision: If you later discover a business dependency on XML-RPC, revert this snippet and move to rate limiting instead of full deny.

Task 11: Rate-limit wp-login.php and wp-admin endpoints (throttle, don’t brick)

Rate limits should target abusive patterns, not normal editorial work. Keep limits modest and monitor.

cr0x@server:~$ sudo tee /etc/nginx/snippets/wordpress-login-ratelimit.conf >/dev/null <<'EOF'
limit_req_zone $binary_remote_addr zone=wp_login:10m rate=10r/m;

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

location ~* ^/wp-admin/ {
  limit_req zone=wp_login burst=40 nodelay;
  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: You’re applying per-IP request limiting to login/admin paths. Burst allows short legitimate spikes (page loads) without punishing humans.

Decision: If you see lots of 429s for real users, loosen rate or increase burst. If you see sustained attacks, tighten and add Fail2ban on top.

Task 12: Confirm headers and cookie behavior aren’t sabotaging logins

cr0x@server:~$ curl -I https://example.com/wp-login.php | egrep -i 'set-cookie|strict-transport|content-security|x-frame|x-content-type'
strict-transport-security: max-age=31536000; includeSubDomains
x-frame-options: SAMEORIGIN
x-content-type-options: nosniff

What it means: You have baseline security headers. Missing headers aren’t always a login breaker, but misconfigured cookie flags across HTTP/HTTPS can be.

Decision: If logins loop or cookies don’t stick, validate TLS termination, WP_HOME/WP_SITEURL, and proxy headers (see common mistakes).

Task 13: Check whether wp-login is being hammered (access logs)

cr0x@server:~$ sudo awk '$7 ~ /wp-login.php/ {print $1}' /var/log/nginx/access.log | sort | uniq -c | sort -nr | head
  842 203.0.113.50
  611 198.51.100.24
  402 192.0.2.10

What it means: Top source IPs hitting login. Large counts suggest brute force or credential stuffing.

Decision: If a few IPs dominate, Fail2ban can help. If thousands of IPs spread the load, prefer CDN/WAF challenges and rate limits.

Task 14: Install and validate Fail2ban for WordPress (Nginx example)

cr0x@server:~$ sudo apt-get update
Hit:1 http://archive.ubuntu.com/ubuntu jammy InRelease
Reading package lists... Done
cr0x@server:~$ sudo apt-get install -y fail2ban
Setting up fail2ban (0.11.2-6) ...

Create a filter matching repeated failed logins (you’ll need log_format including request path and status; adapt to your format). Example jail:

cr0x@server:~$ sudo tee /etc/fail2ban/jail.d/wordpress-login.conf >/dev/null <<'EOF'
[wordpress-login]
enabled = true
port = http,https
filter = wordpress-login
logpath = /var/log/nginx/access.log
findtime = 600
bantime = 3600
maxretry = 20
EOF
cr0x@server:~$ sudo tee /etc/fail2ban/filter.d/wordpress-login.conf >/dev/null <<'EOF'
[Definition]
failregex = ^<HOST> .* "(GET|POST) /wp-login\.php.*" (200|401|403|404) .*
ignoreregex =
EOF
cr0x@server:~$ sudo systemctl restart fail2ban
cr0x@server:~$ sudo fail2ban-client status wordpress-login
Status for the jail: wordpress-login
|- Filter
|  |- Currently failed: 0
|  |- Total failed: 0
|  `- File list:        /var/log/nginx/access.log
`- Actions
   |- Currently banned: 0
   |- Total banned: 0
   `- Banned IP list:

What it means: The jail is active. “Currently failed” increments when regex matches. “Currently banned” shows active bans.

Decision: Tune failregex to your actual access log format. Don’t deploy Fail2ban rules you haven’t tested, unless you enjoy banning your CEO’s hotel Wi‑Fi.

Task 15: Verify TLS termination and proxy headers (a frequent login loop culprit)

cr0x@server:~$ sudo -u www-data wp option get siteurl
https://example.com
cr0x@server:~$ sudo -u www-data wp option get home
https://example.com

What it means: WordPress thinks it’s HTTPS. If you terminate TLS at a load balancer and forward HTTP to origin, you must also forward X-Forwarded-Proto and configure WordPress accordingly.

Decision: If these options are http:// while users hit https://, fix them and ensure your reverse proxy sets X-Forwarded-Proto https.

Task 16: Check PHP-FPM saturation (login failures under load)

cr0x@server:~$ sudo tail -n 20 /var/log/php8.2-fpm.log
[04-Feb-2026 03:12:41] WARNING: [pool www] server reached pm.max_children setting (10), consider raising it

What it means: PHP workers are exhausted. Under attack, this looks like “logins broken,” but it’s resource starvation.

Decision: Raise pm.max_children only after confirming CPU/RAM headroom. Also reduce work per request (cache, limit expensive plugins, add WAF/rate limits).

Task 17: Confirm database health (slow auth queries can look like security blocks)

cr0x@server:~$ mysql -e "SHOW PROCESSLIST\G" | sed -n '1,40p'
*************************** 1. row ***************************
     Id: 41
   User: wpuser
   Host: 127.0.0.1:48822
     db: wordpress
Command: Query
   Time: 12
  State: Sending data
   Info: SELECT option_name, option_value FROM wp_options WHERE autoload = 'yes'

What it means: The autoloaded options query is taking time. This often balloons over years as plugins stash junk in options.

Decision: Audit autoload size and cut it down. Hardening doesn’t help if the login endpoint is spending seconds hauling a dumpster of options every request.

Task 18: Check disk pressure (because logins need IO too)

cr0x@server:~$ df -h /var/www /var/log | tail -n 2
/dev/sda1        80G   74G  2.1G  98% /
/dev/sda1        80G   74G  2.1G  98% /

What it means: The filesystem is nearly full. This breaks sessions, uploads, logging, updates, and sometimes database writes. It also makes every security tool noisier.

Decision: Free space immediately. Then separate logs/uploads from root filesystem, and add alerting before it hits 90%.

Joke #2: Disk at 98% is the server’s way of saying “I’m not mad, I’m just disappointed.”

Three corporate mini-stories from the trenches

Incident #1: A wrong assumption (IP allowlisting “for security”) caused a lockout spiral

A mid-sized company ran a WordPress site for support content and lead capture. After a mild scare—some suspicious login attempts in the logs—an engineer did what many of us have done under pressure: they locked down /wp-admin and /wp-login.php to the corporate office IP range. It worked instantly. The login noise stopped. The dashboard felt peaceful.

Then the sales team went back on the road. Home Wi‑Fi, airports, hotels, mobile tethering. Suddenly, “WordPress is down” reports spiked, because from their perspective, it was. The engineering team assumed it was user error, then assumed it was VPN, then assumed it was the IdP. Meanwhile the marketing ops team began sharing one admin account “just to get work done,” because only one person still had access.

The failure mode got worse: an external agency that managed SEO could no longer access the site. They asked for access; someone temporarily opened the allowlist to “anywhere,” forgot to remove it, and the login endpoint got hammered again. The team ended up with the worst of both worlds: lockouts for real users, and exposure to the original threat.

The fix wasn’t heroic. They replaced allowlisting with layered controls: strong passwords + 2FA, a modest Nginx rate limit on wp-login.php, and Fail2ban for repeated login attempts. They also ensured the origin wasn’t reachable except through the CDN/WAF. Access stabilized, and the attack noise fell to a manageable, observable hum.

The lesson: IP allowlists are not “security.” They’re a brittle identity system. Use them for infrastructure admin panels and private staging. For WordPress logins in 2026, default to throttling and strong auth.

Incident #2: An optimization that backfired (aggressive caching broke authentication)

A different org ran WordPress behind Nginx with a caching layer. Performance was a top KPI; the team cared about load time more than most teams care about weekends. An engineer added caching rules to speed up dynamic pages. The hit ratio went up. CPU went down. High fives all around.

Within hours, support tickets appeared: users couldn’t log in, or they’d log in and immediately be logged out. Some saw other users’ admin bars flicker briefly—rare, but terrifying. The engineering team initially suspected XSS or session theft. Panic is loud.

The real culprit was boring: the caching layer cached responses it should never have cached. Some pages were served with inappropriate cookies, and the cache didn’t vary correctly by authentication state. The optimization was correct for anonymous content and catastrophic for anything related to sessions.

The recovery was a careful rewrite of caching rules: never cache /wp-login.php, never cache /wp-admin, and avoid caching pages when auth cookies are present. They also added explicit cache-bypass conditions for WooCommerce and membership cookies. After that, login stability returned, and the performance gains remained—just not on the endpoints where correctness is non-negotiable.

The lesson: performance tuning can be a security incident if it breaks isolation. Cache like a grown-up: define what must not be cached, then verify with tests and headers.

Incident #3: A boring but correct practice saved the day (least privilege + restore drills)

A SaaS company hosted a WordPress marketing site that looked simple but had serious business value: pricing pages, signup flows, and customer stories used in enterprise deals. They treated it like production, because it was. The team had a policy that sounded dull: file ownership locked down, no in-place plugin installs on production, and monthly restore drills of both database and uploads.

One weekend, a plugin vulnerability hit the news. Bots moved fast. Their site began receiving malicious requests targeting that plugin’s upload endpoint. Some requests got through to PHP, but the exploit failed to persist because the web user couldn’t write to the core directories or plugin directory. Attackers could poke; they couldn’t set up camp.

Still, they didn’t declare victory. They assumed “attempts succeeded somewhere.” They ran integrity checks, compared plugin directories against known-good artifacts, and inspected logs for anomalous POSTs. They rotated keys/salts and forced password resets for admin users as a precaution.

The real win came from the boring practice: the team had a tested restore procedure. They took a snapshot, restored into an isolated environment, and validated the site behavior and file tree. It wasn’t needed for recovery this time, but it turned fear into a controlled verification step. No guessing, no superstition.

The lesson: when you can restore reliably, you can harden aggressively. Not because you plan to fail, but because you refuse to be trapped by failure.

Common mistakes: symptoms → root cause → fix

Login page returns 403 for everyone

Symptoms: All users see 403 on /wp-login.php. Admin access dead. Editors screaming.

Root cause: Overbroad deny rule (Nginx/Apache), WAF rule set to “block” rather than “challenge,” or a geo/IP restriction applied to the login endpoint.

Fix: Roll back the rule by targeting only /xmlrpc.php or known bad paths; switch to rate limiting; add an emergency bypass path accessible from VPN only.

Users can load wp-login, but credentials never stick (login loop)

Symptoms: User submits correct password, gets redirected back to login. Cookies don’t persist.

Root cause: HTTPS termination mismatch (siteurl/home wrong), missing X-Forwarded-Proto, or caching of login responses.

Fix: Correct home and siteurl to HTTPS, configure proxy headers, disable caching for auth endpoints, verify COOKIE_DOMAIN and canonical host.

Some users get 429 Too Many Requests while others are fine

Symptoms: Remote teams complain; office users okay. Mobile app fails intermittently.

Root cause: Rate limiting keyed incorrectly (e.g., by shared NAT IP, or by a header that collapses distinct users into one bucket).

Fix: Key rate limits by $binary_remote_addr at the edge; if behind a proxy, ensure real client IP is correctly set (real_ip module). Increase burst. Add CAPTCHA/challenges only where humans are expected.

Admin pages are slow, logins time out under load

Symptoms: 504/502 errors, intermittent login failures, CPU and IO wait climb.

Root cause: PHP-FPM saturation, database slow queries, or disk nearly full. Sometimes a brute force attack just amplifies underlying inefficiency.

Fix: Increase PHP-FPM capacity if resources allow, add caching properly, tune database, reduce autoloaded options, and add upstream protection (WAF/rate limits).

After “hardening,” plugin updates and media uploads fail

Symptoms: “Could not create directory,” update failures, permissions errors.

Root cause: Ownership/permissions too strict or inconsistent across webroot and wp-content.

Fix: Decide: deployment-based updates (preferred) or allow controlled write access to wp-content. Don’t make core writable to solve a plugin workflow.

Fail2ban bans real users repeatedly

Symptoms: Users locked out after a few attempts; bans correlate with office/VPN NAT.

Root cause: Regex matches too broad, maxretry too low, NAT concentrates many users behind one IP.

Fix: Increase maxretry, shorten bantime, tune regex to actual failures (e.g., 200 on wp-login is not always “failure”), and prefer WAF challenges where possible.

Checklists / step-by-step plan (don’t improvise in prod)

Phase 0: Pre-flight (do this before touching security controls)

  1. Inventory dependencies: Do you use XML-RPC? Jetpack? Mobile publishing? Any external systems that hit REST endpoints?
  2. Decide where enforcement lives: CDN/WAF first, then web server, then app plugins. Don’t put everything inside WordPress if you can avoid it.
  3. Establish emergency access: A VPN path, a bastion, or a temporary maintenance procedure that does not rely on WordPress login itself.
  4. Backups + restore test: Verify you can restore database and wp-content/uploads into a clean environment.
  5. Change window and rollback: Know exactly what config files and rules you are changing, and how to revert fast.

Phase 1: Remove easy wins (lowest risk, highest payoff)

  1. Delete unused plugins and themes. Inactive isn’t safe; it’s just idle code.
  2. Update WordPress core and plugins. Stage first if the site is revenue-critical.
  3. Set correct permissions and ownership. Keep core read-only to runtime; keep uploads writable.
  4. Regenerate salts/keys if you suspect compromise or if the site has been migrated through multiple hands.

Phase 2: Protect the login without breaking it

  1. Rate-limit /wp-login.php at the web server or edge. Avoid hard denies unless you’re sure.
  2. Enable 2FA for admins (or SSO with strong policy). This is the single biggest login-specific risk reducer.
  3. Disable XML-RPC if unused; otherwise restrict and monitor.
  4. Limit admin creation paths (don’t let random plugin roles escalate; audit users regularly).

Phase 3: Add guardrails and visibility

  1. Centralize logs (at least Nginx/Apache access + error logs, PHP-FPM logs, and auth-relevant app logs).
  2. Alert on anomalies: spikes in /wp-login.php requests, 403/429 increases, disk usage, PHP-FPM saturation, DB slow query rates.
  3. File integrity monitoring for core and plugin directories (hashes or checksums). If it changes outside deployments, that’s a page.
  4. Practice incident response: isolate origin, rotate credentials, restore to a clean environment, verify.

“Don’t do this” list (because you will be tempted)

  • Don’t hide /wp-admin with security-by-obscurity plugins and call it “done.” Bots crawl, and humans forget.
  • Don’t set permissions to 777 to “fix” updates. That’s not fixing; that’s surrender.
  • Don’t rely on IP allowlists for users who travel. That’s how you create shadow admin accounts.
  • Don’t deploy untested caching rules on auth endpoints. If you must, test with multiple user sessions and cookies.

FAQ

1) Should I disable xmlrpc.php?

If you don’t use features that depend on it, yes—block it at the web server. If you do use it (Jetpack, some legacy publishing), keep it but throttle and monitor.

2) Is changing the login URL a real security control?

It’s noise reduction, not a primary control. It can cut down on dumb bots, but it won’t stop targeted attacks or exploited plugins. Use it only if it doesn’t break integrations and you still have rate limiting and strong auth.

3) Will rate limiting break legitimate users behind NAT?

It can. That’s why you set reasonable rates and bursts, and you key correctly on the real client IP. Start permissive, watch 429s, then tighten.

4) Is Fail2ban enough without a WAF/CDN?

Fail2ban helps when attacks concentrate on repeat IPs. Credential stuffing often spreads across many IPs, where edge protections do better. In practice: use both if the site matters.

5) What file permissions should WordPress have?

Core should be read-only to the runtime user. Uploads must be writable. A common pattern: core owned by root, group www-data, with 0640 files and 0750 dirs; uploads owned by www-data.

6) Should I allow WordPress to update plugins from the dashboard?

For personal sites, maybe. For production business sites, prefer deployment-based updates so the web runtime can’t write executable code. If you must allow it, constrain write permissions to wp-content and monitor integrity.

7) Why do logins fail after enabling a security header policy?

Overly strict Content Security Policy (CSP) can block scripts on the login page, especially with custom themes or plugins injecting assets. Roll out CSP in report mode first, then tighten.

8) How do I know if the origin is exposed behind the CDN?

Check DNS and firewalling. If the origin IP is reachable and serves WordPress directly, attackers can bypass your edge controls. Lock origin down to CDN IP ranges or a private network path.

9) Do I need a security plugin?

Not always. Server-level controls (rate limits, WAF, permissions, updates, monitoring) do most of the heavy lifting. Security plugins can add 2FA and convenience features, but they’re also more code in your app.

10) What’s the minimum I should do today if I’m overwhelmed?

Update core/plugins, remove unused plugins/themes, lock permissions, add rate limiting on /wp-login.php, and enable 2FA for admins. Then add logging/alerts so you can see what’s happening.

Conclusion: next steps that actually move the needle

Hardening WordPress without breaking logins is mostly about restraint. Don’t swing the ban-hammer first. Reduce the surface area, lock down what’s writable, throttle abuse, and add strong authentication. Then instrument everything so your security controls don’t become the next outage.

Do this next:

  1. Run the integrity and inventory steps (WP-CLI checksums, plugin list, permissions).
  2. Implement web-server rate limiting for /wp-login.php and block /xmlrpc.php if unused.
  3. Turn on 2FA for admins (or enforce SSO policy) and audit admin accounts.
  4. Verify proxy/TLS correctness to prevent login loops.
  5. Add alerting: 403/429 spikes, PHP-FPM max_children warnings, disk usage, and login request rates.
  6. Schedule a restore drill. Not because you’re paranoid—because you like sleeping.
← Previous
MSI/MSI-X + Interrupt Remapping: The 5‑Minute Fix for Random VM Stutters
Next →
NVMe Passthrough vs VirtIO: The Performance Winner Nobody Mentions

Leave a comment