Most WordPress hardening advice ends at "turn it on." Almost none of it tells you how to confirm the thing you turned on is doing anything, which is a problem, because the two most common outcomes of configuring a security plugin are a control that silently does nothing and a control that silently breaks your site.
Both look identical from the dashboard. The panel says enabled either way.
What follows is the check I run after configuring hardening on a site, control by control. It is four commands and a rollback drill, and it takes about ten minutes. Everything here runs against a site you own.
Why verification matters more than coverage here
Patchstack's State of WordPress Security in 2026 put 11,334 vulnerabilities across the WordPress ecosystem in 2025, 91% of them in plugins and 9% in themes, with six in core, all low risk. The exposure is the code you installed.
The number that decides your strategy is the next one: 46% of those had no patch available at disclosure, and the weighted median time to first exploitation was five hours. Patching is necessary and it is not, on its own, a plan. So you run a hardening layer underneath it, and the hardening layer is only worth what you can prove it does.
1. Prove the default paths return 404, and that PHP never ran
The first control is reconfiguring the default endpoints so that a scan against wp-login.php, wp-admin, wp-json and the plugin and theme directories fails at reconnaissance.
Checking the status code is the easy half:
SITE="https://example.com"
for p in wp-login.php wp-admin/ wp-json/ wp-content/plugins/ xmlrpc.php; do
code=$(curl -s -o /dev/null -w '%{http_code}' -L --max-time 10 "$SITE/$p")
printf '%-26s %s\n' "$p" "$code"
done
A 404 or 403 on the paths you reconfigured is what you want. A 200 on wp-login.php means the setting did not apply, which usually means a caching layer served you a stale page or the rewrite rules were never written.
The harder and more useful half is finding out where that 404 came from. A 404 emitted by the web server from a rewrite rule is a different security property from a 404 emitted by WordPress after it booted, loaded the plugin stack, and decided to deny you. The first one means the exploit path never executed. The second means every line of PHP ran and then politely declined.
Two signals discriminate them. Headers first:
curl -sSI "https://example.com/wp-login.php" | \
grep -iE 'HTTP/|server|x-powered-by|set-cookie|link|x-redirect-by'
A rewrite-layer rejection is bare: a status line, a server header, a content type, little else. If you see x-powered-by: PHP/8.x, a Set-Cookie carrying a WordPress cookie, or a Link: header advertising the REST API, then WordPress ran. That is the application-layer version of the control.
Then timing, against a known-static file as your baseline:
FMT='%{http_code} ttfb=%{time_starttransfer}s total=%{time_total}s\n'
curl -s -o /dev/null -w "$FMT" https://example.com/robots.txt
curl -s -o /dev/null -w "$FMT" https://example.com/wp-login.php
curl -s -o /dev/null -w "$FMT" https://example.com/definitely-not-a-real-path-9f2a
If the blocked path's TTFB sits close to robots.txt, the request died before PHP. If it sits close to a normal WordPress 404, WordPress handled it. Run each three or four times; a single sample on shared hosting tells you nothing.
One caveat worth stating out loud: this is measuring where enforcement happens, not whether an attacker can guess the new path. Those are separate questions and only the first one is testable with curl.
2. Prove the firewall ruleset is actually loaded
A 7G or 8G ruleset rejects injection strings, traversal attempts and malformed requests by pattern. On Apache and LiteSpeed those land as rewrite directives, so they are evaluated before PHP.
Confirm the rules exist on disk first, rather than trusting the panel:
# Apache / LiteSpeed
grep -nE '7G|8G|RewriteCond|RewriteRule' /path/to/site/.htaccess | head -40
# nginx: rules live in the server block, not in .htaccess
sudo nginx -T 2>/dev/null | grep -nE 'location|deny|return 403' | head -40
On nginx this step matters more than people expect. Plenty of plugins write .htaccess unconditionally, and on nginx that file is inert. The panel reports success, the rules are never read, and nothing in the UI says so.
Then send a request that trips a pattern, against your own site:
curl -s -o /dev/null -w '%{http_code}\n' \
--get --data-urlencode 'p=../../../../etc/passwd' https://example.com/
curl -s -o /dev/null -w '%{http_code}\n' \
--get --data-urlencode "s=1' UNION SELECT 1,2,3-- -" https://example.com/
403 means the ruleset fired. 200 means the request reached WordPress and was handled as an ordinary query, which is not itself a vulnerability but does tell you the layer you thought was in front is not in front.
Do not over-read a pass here. Patchstack's 2025 pentest of common defences, covering internal host WAFs, Cloudflare, Imunify360 and ModSecurity, found they blocked 12% of attacks against known-exploited vulnerabilities, rising to 26% on a broader test. The best host in the set reached 60.7%. One blocked nothing. A pattern ruleset that answers 403 to a traversal string is working as designed, and working as designed still leaves most real exploit traffic to something else.
3. Prove the login throttle counts, and find the number
Rate limiting is the control most often enabled and least often verified, because verifying it means locking yourself out on purpose. Do that deliberately, on your own site, with a second browser session already open somewhere else.
LOGIN="https://example.com/your-configured-login-path"
for i in $(seq 1 12); do
code=$(curl -s -o /dev/null -w '%{http_code}' \
-d "log=verify-throttle-$i&pwd=wrong-on-purpose" \
-H 'Content-Type: application/x-www-form-urlencoded' \
--max-time 10 "$LOGIN")
echo "attempt $i -> $code"
sleep 1
done
You are looking for the attempt number where the response changes: a 403, a 429, or a body that stops being the login form. That number is your actual lockout threshold, which is frequently not the number in the settings field, because a caching layer or a proxy in front can absorb attempts before they are counted.
While you are here, check what the site does with XML-RPC, which is a second credential endpoint that a login throttle configured only for the form may not cover:
curl -s -X POST https://example.com/xmlrpc.php \
-H 'Content-Type: text/xml' \
-d '<methodCall><methodName>system.listMethods</methodName><params></params></methodCall>' \
| head -c 400
A method list coming back means the endpoint is live. system.multicall is the reason people care: it lets an attacker bundle many credential attempts into a single HTTP request, so a throttle that counts requests rather than attempts undercounts badly.
4. Prove 2FA covers the paths that actually carry credentials
The Verizon 2025 Data Breach Investigations Report found 88% of Basic Web Application attacks involved stolen credentials, which is why a second factor is on the short list at all. Passkeys are the strongest form, because the challenge is bound to your domain and a phished credential cannot be replayed elsewhere.
The gap worth checking is that 2FA usually protects the form, and the form is not the only way credentials enter your site. Application passwords authenticate over REST and are unaffected by two-factor, captcha, throttling, or a reconfigured login path. They are issued once and do not expire on their own.
List what exists:
wp user list --field=ID | while read -r id; do
echo "== user $id"
wp user application-password list "$id" --format=table 2>/dev/null
done
Then confirm the credential works without ever touching your login page:
curl -s -u 'username:xxxx xxxx xxxx xxxx xxxx xxxx' \
https://example.com/wp-json/wp/v2/users/me | head -c 300
If that returns your user object, then every login control you configured is out of the request path for that credential, by construction. Which is fine when you provisioned it deliberately and know it exists. It is a problem when it was issued for a mobile app in 2023 by somebody who has left.
Revoke what you cannot account for:
wp user application-password delete <user-id> <uuid>
5. Rehearse the rollback before you need it
This is the step that decides whether any of the above survives, and it is the one nobody does.
Find your recovery path while you can still log in, then actually run it once:
# WP-CLI, if you have shell access
wp plugin deactivate <plugin-slug>
wp rewrite flush --hard
# no shell: rename the plugin directory over SFTP
mv wp-content/plugins/<plugin-slug> wp-content/plugins/<plugin-slug>.off
# and keep a known-good copy of the rewrite rules before you change anything
cp .htaccess .htaccess.pre-hardening
Two things to confirm during the drill. First, that deactivating restores the default paths immediately rather than leaving a half-applied rewrite state, which is the difference between a plugin that writes rules and one that also modifies core files. Second, that you know your vendor's documented bypass URL, if it has one, and that it is bookmarked somewhere you can reach from a phone.
A control you cannot reverse under pressure is a control you will eventually disable permanently at 2am, and a permanently disabled control protects nothing.
The short version
Four commands and a drill:
-
curl -Ithe default paths, and use TTFB against a static file to tell a rewrite-layer 404 from a PHP one. -
grepthe actual server config for the ruleset, then trip one pattern and expect 403. On nginx, check that.htaccessis not being written into the void. - Loop failed logins until the status code changes, and find out whether the real threshold matches the configured one. Check
xmlrpc.phpseparately. - List application passwords, prove one authenticates without the login form, and revoke what you cannot account for. Then rehearse the rollback while you are still logged in.
What is the check you run that is not on this list? I am specifically after the one that caught something the dashboard reported as fine.
Top comments (0)