Ten years cleaning up hacked WordPress sites. The zero-days I’ve seen? I can count them on one hand. Almost every site I’ve untangled fell to boring stuff that had been sitting in the open for a decade: a missing security header, or an exposed readme.html, or a TLS certificate that quietly expired three days ago. The default admin username any bot reads straight off ?author=1. None of it clever. So here’s what I actually do when a site lands on my desk. These 18 checks, between them, swat away maybe 90 percent of the automated junk thrown at WordPress in 2026. Each one comes with the exact config line you paste into nginx, Apache or functions.php. Cold site, no prior hardening? You can knock the whole list out before lunch. Call it 90 minutes.
Table of contents
The state of WordPress security in 2026
WordPress still runs roughly 43 percent of the web in 2026, and honestly that’s the whole problem right there. Biggest target, most arrows. Look at it from the attacker’s side for a second: write one scraper that hammers /xmlrpc.php and /wp-login.php across the entire IPv4 range, leave it running a week, and you’ve poked a huge slice of the internet for free. Now flip it around. Close the eighteen tired old holes in this guide and that same attacker has to either find a real authenticated plugin bug or actually social-engineer somebody, and both of those cost a fortune next to a drive-by scan. You won’t be unhackable. Nobody is. You’ll just stop being worth the bother, and against automated scanners that’s about all you need.
Two things genuinely changed since 2023, and I’ve watched both land on real clients. AI doing the reconnaissance, for one: a bot scrapes an admin’s name off a leaked author archive, then spits out a spear-phish that’s good enough to fool a tired person at 5 p.m. The other is “form spam as a service.” I had a client once whose single unprotected contact form was relaying enough junk to basically pay some spammer’s hosting bill. Both of those reward the same dull discipline. Know what of yours is public. Then shut it.
HTTP security headers, the foundation
Five minutes on a site? This is where I spend them. Headers cost nothing and, set right, they break nothing, and the browser does the enforcing for you on every visitor’s machine. My 2026 baseline is six of them. I want all six on every public page, no exceptions.
HSTS (Strict-Transport-Security)
HSTS is just you telling the browser “for this domain, plain HTTP is dead to you” for a set window. That kills the whole family of downgrade tricks on sketchy coffee-shop Wi-Fi, and it stops a stolen cookie getting replayed over an insecure link. I set it to a year, cover the subdomains, make it preload-eligible:
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
Set this one at the web server (nginx add_header, Apache Header always set), not in a plugin. Plugins come and go, and I don’t want my transport security riding on whatever happens to be installed this month. Run it for a month with no HTTPS surprises, then submit the domain to hstspreload.org and it ships baked into every major browser.
Content-Security-Policy
CSP is the strongest XSS brake you’ve got, full stop. Lock it down and the browser flat-out won’t run a script or load a stylesheet from anywhere you didn’t put on the list. I’ll be honest though: getting there on an existing WordPress site is rarely a one-shot job, because old themes drag in resources from a dozen origins you forgot you were even using. Start strict, then loosen:
Content-Security-Policy: default-src 'self'; img-src 'self' data: https:; style-src 'self' 'unsafe-inline'; script-src 'self'; frame-ancestors 'self'; base-uri 'self'; form-action 'self';
Do yourself a favor and run Content-Security-Policy-Report-Only for a couple of weeks before you flip it to enforcing. Report-only logs every violation and blocks nothing, so you get to see which third-party origins your site actually leans on. You find them in a log. Not in an angry email about the checkout being broken.
X-Frame-Options and frame-ancestors
This is your clickjacking guard. Two ways to set it: X-Frame-Options: SAMEORIGIN, or frame-ancestors 'self' inside your CSP. The CSP one is the modern way. The header is the old fallback for anything that still doesn’t speak CSP. So set both. They don’t conflict, and you’re covered either way.
The three “minor but free” headers
These I just paste in and forget. X-Content-Type-Options: nosniff stops the browser guessing a file’s type when it shouldn’t, which shuts a small-but-real XSS door. Referrer-Policy: strict-origin-when-cross-origin keeps you from leaking full URLs to every site you link out to. Then Permissions-Policy: camera=(), microphone=(), geolocation=(), interest-cohort=() switches off browser features a blog has no business touching. Grand total: about 200 bytes a response. Your privacy posture gets a quiet, free bump for it.
TLS certificate hygiene
A valid cert is the price of entry. One that keeps renewing itself? That’s the real tell someone’s actually watching the site. I’ve lost count of the times I got called in for an “outage” that was just a dead certificate. Box was perfectly secure. The auto-renewal had quietly choked on a Let’s Encrypt rate limit, or a botched DNS challenge. One memorable time, a password the host had changed without telling anyone. And the ending never varies. Every browser on every phone throws that big red warning, and traffic just bleeds out for hours while nobody notices.
So treat the cert as a thing you check, not a thing you install once and forget about. I look at the days remaining at least weekly with our SSL Certificate Checker or via SecurityWatch. And the trick that’s saved me more than anything else: two renewal triggers, not one. Run the Let’s Encrypt cron job, then put an external monitor on top of it, so when one fails the other screams. Got a pile of subdomains? A wildcard cert keeps your life simple, and staying on one CA across all of them turns the audit into a five-minute job instead of a scavenger hunt. One last thing for 2026. If TLS 1.3 isn’t your preferred protocol yet, you’re behind, and tossing HTTP/3 / QUIC on top is a nice win for the mobile crowd.
WordPress version disclosure
Hand an attacker your exact WordPress version and you’ve basically given them a shopping list. They pull up the matching CVEs faster than you can read this sentence. And WordPress gives that version away for free in a few spots. There’s a <meta name="generator" content="WordPress X.Y.Z"> tag on every single page, plus a ?ver= tacked onto every CSS and JS file it loads. Then /readme.html, sitting right at your document root. All of it has to go.
Kill the generator tag with a few lines in your theme:
// functions.php
remove_action('wp_head', 'wp_generator');
add_filter('the_generator', function () { return ''; });
Strip the ?ver= off your assets with a filter:
add_filter('style_loader_src', 'pag_strip_ver', 9999, 2);
add_filter('script_loader_src', 'pag_strip_ver', 9999, 2);
function pag_strip_ver($src, $handle) {
return remove_query_arg('ver', $src);
}
And /readme.html, core updates love to drop a fresh copy back in, so either delete it after every update or just block it at the server and stop thinking about it:
# nginx
location = /readme.html { deny all; }
# Apache
<Files "readme.html">Require all denied</Files>
xmlrpc.php, REST users and default exposures
xmlrpc.php is the old brute-force back door, and it’s nastier than it looks. It authenticates over an RPC protocol that sails straight past most rate-limiting plugins. Worse, its system.multicall method lets an attacker cram hundreds of password guesses into a single request, so your “5 failed logins and you’re locked out” rule never even fires. And it’s the engine behind the pingback DDoS amplification that once turned innocent WordPress sites into a botnet for hire. Unless you actually lean on the Jetpack mobile apps or remote publishing, shut it:
# nginx
location = /xmlrpc.php { deny all; return 403; }
# Apache
<Files "xmlrpc.php">Require all denied</Files>
Next, the one people forget: the REST endpoint /wp-json/wp/v2/users. Out of the box it cheerfully hands back every user who can publish, which means an attacker reads your admin username in a single GET request. No brute force needed, no guessing. They just ask, and WordPress tells them. Lock it for anyone who isn’t logged in:
add_filter('rest_endpoints', function ($endpoints) {
if (!current_user_can('list_users')) {
unset($endpoints['/wp/v2/users']);
unset($endpoints['/wp/v2/users/(?P<id>[\\d]+)']);
}
return $endpoints;
});
One more freebie: open directory listings on /wp-includes/ and /wp-content/uploads/. If those are browsable, you’re handing over your whole file layout plus the versions of everything running, core and plugins and themes, basically a map of every door worth knocking on. Turn autoindex off globally, nginx (autoindex off;) or Apache (Options -Indexes). I also like dropping an empty index.html into the writable folders, so even a box the next person misconfigures shows a blank page instead of your laundry.
User enumeration and login hardening
The other big leak on a stock install is author-archive enumeration. Same problem as the REST one, different door. Someone visits https://yoursite.com/?author=1 and WordPress helpfully bounces them to /author/admin-login/, spelling your login name right out in the URL bar. Now they’ve got the username for free, and only the password stands between them and you. Slam that redirect shut:
add_action('template_redirect', function () {
if (isset($_GET['author'])) {
wp_redirect(home_url(), 301);
exit;
}
});
After that, login hardening is a short list I never skip. 2FA on every admin account, no exceptions, and the Two Factor plugin from the core team is the path of least resistance. A real password manager, whichever one (Bitwarden, KeePass, pick your poison), because they all beat reusing your email password, and reusing your email password is, hands down, the mistake I watch burn people the most. And rate-limiting on failed logins, since core flat-out won’t do it for you. Bolt on Limit Login Attempts Reloaded, or do what I prefer and kill the brute-force traffic at the edge with fail2ban before it ever reaches PHP.
Plugins, themes and the supply chain
Here’s where the fire actually is in 2026. Core bugs have gone scarce. The WordPress security team is genuinely good these days, so the attackers moved to where the wood is dry: your plugins and themes. Patchstack and Wordfence keep saying the same thing every quarter, that the overwhelming majority of new CVEs land on plugins, not core. A handful of habits cut that risk way down:
- Minimise plugin count. Every plugin you install is another door someone else built into your house. I go through the list once a quarter and rip out anything I’m not actually using, because that “eh, might need it later” plugin sitting there deactivated and unpatched? That’s exactly the one that gets popped.
- Prefer plugins with recent updates. No update in 18 months is a yellow flag for me. Hit 36 and it’s dead to me, I assume nobody’s home and go find an alternative that’s still being maintained.
- Subscribe to a CVE feed. Patchstack, Wordfence and the WordPress vulnerability database all push RSS or JSON feeds. Wire one into Slack or your monitor so a critical advisory pings you the day it drops. Not three months later, when a scanner finds it already running on your live site.
Themes follow the same logic, plus one hard rule I’ll die on: never, ever hack on a theme directly. Use a child theme. Always. Your updates keep flowing, and six months out whoever audits the site (could well be you) can tell at a glance what’s custom and what came from upstream. Edit the parent theme directly and you’ve built the classic landmine. Site hums along fine, someone clicks “update theme,” and now everything’s on fire and nobody remembers what got changed.
The full 18-point checklist
Everything in one place. I run it at launch, then every quarter, then once more after any core update. That last pass is the one that catches the readme.html WordPress just snuck back in behind your back.
| # | Check | Severity if missing |
|---|---|---|
| 1 | HSTS header with max-age >= 15552000 + includeSubDomains | High |
| 2 | Content-Security-Policy at least at default-src ‘self’ | High |
| 3 | X-Frame-Options: SAMEORIGIN or frame-ancestors ‘self’ in CSP | Med |
| 4 | X-Content-Type-Options: nosniff | Low |
| 5 | Referrer-Policy: strict-origin-when-cross-origin | Low |
| 6 | Permissions-Policy with explicit deny on unused features | Low |
| 7 | TLS certificate with > 30 days remaining + auto-renew alert | High |
| 8 | TLS 1.3 enabled, TLS 1.0 / 1.1 disabled | Med |
| 9 | WordPress generator meta removed | Med |
| 10 | ?ver= stripped from asset URLs | Low |
| 11 | readme.html deleted or blocked at web server | Med |
| 12 | xmlrpc.php blocked (unless used by Jetpack) | Med |
| 13 | /wp-json/wp/v2/users blocked for unauthenticated callers | High |
| 14 | /wp-includes/ directory listing disabled | High |
| 15 | /wp-content/uploads/ directory listing disabled | Med |
| 16 | ?author=N redirect blocked or rewritten | Med |
| 17 | 2FA enforced on every administrator account | High |
| 18 | Plugin and theme inventory reviewed last 90 days | Med |
Want this scan automated?
SecuChecker throws all 18 checks at any URL in about 20 seconds, then hands you a posture score plus the exact fix for whatever it finds open. It’s free and it runs right in your browser. Never touches your server.
FAQ
How often should I run a security audit on a WordPress site?
Quarterly is my floor. Plus a pass after every core update, and any time you add or pull a plugin, since those are the moments things quietly change under you. Busy store where downtime costs real money? I’d go weekly and let SecurityWatch do the watching, so you don’t have to remember to.
Is HSTS preload safe for a small site?
Yes, but treat it like a one-way door, because it basically is. Once you’re on the preload list, crawling back to plain HTTP is slow and miserable, and removal takes weeks even when it goes smoothly. So run HSTS from your server config for a solid month first. Prove HTTPS never hiccups. Then submit. Don’t preload on day one and just hope.
Will blocking xmlrpc.php break Jetpack?
It can, yeah. You’ll lose the Jetpack mobile apps and a few remote-publishing bits. If you actually use those, don’t just nuke the file. Allow-list xmlrpc.php for the Jetpack IP ranges they publish in their docs, and you keep both. But if you’ve never once opened the Jetpack app or published remotely? Blocking it is the easy, correct call, and honestly you’ll never miss it.
Do I need a managed WAF if I do all this?
Need is a strong word. A WAF (Cloudflare, Sucuri, Wordfence Premium) buys you defence in depth, and where it really earns its keep is the zero-day plugin bug that drops in the gap between two of your audits, when you’d otherwise be sitting wide open. So think of it this way. The 18 checks are the work you have to do. A WAF is the seatbelt I’d add on any site where an hour of downtime genuinely hurts.
How do I rotate the admin password without breaking automation?
Stop sharing the admin login with your scripts. That’s the root of the pain right there. Make a dedicated automation user with the lowest role that actually does the job (nine times out of ten that’s Editor, not Administrator), point your CI or CMS automation at that account, and rotate the human admin password on its own schedule. Keep that interval sane too, somewhere around 90 to 180 days. Force a change every two weeks and I promise you it ends up on a sticky note under the keyboard.
My site already has a high SecuChecker score, what next?
Nice. Now stop staring at the score and start building for the day something gets through anyway, because eventually something will. Subscribe to a plugin CVE feed (Patchstack, Wordfence) so you hear about trouble early. Switch on file-integrity monitoring (Wordfence or iThemes Security) so you know the second a file changes that shouldn’t. And run automated daily backups to somewhere off the box, which is the boring one everybody skips until the day they’d have killed for it. A hardened site keeps people out. That stuff is what gets you back on your feet fast when one finally slips in.













