Hardening a WordPress site is a handful of settings and about five minutes of work. The reason people put it off for a year is not the five minutes. It is that one of those settings changes the URL of your login page, and if the rewrite rules do not land the way the interface believes they did, the next thing you see is a 404 where your dashboard used to be.
The usual advice is to be careful. Being careful does not scale and it does not survive a cache layer you forgot about.
What follows is the pre-flight I run instead. It takes about ninety seconds to set up and it makes a bad change expire on its own, so the worst outcome of a mistake is a ten minute wait rather than an FTP session at midnight. Everything runs against a site you own and already have shell access to.
Why bother, on a five minute job
Because the thing you are switching on is doing more work than the layer underneath it, so it is worth getting switched on properly and left on.
According to Patchstack's State of WordPress Security in 2026, 11,334 vulnerabilities were disclosed across the WordPress ecosystem during 2025, up 42% year on year, with 91% of them in plugins. Forty-six percent had no patch available at the moment of disclosure, and the weighted median time from disclosure to first exploitation was five hours.
The same report pentested common hosting defences. Internal WAFs, Cloudflare, Imunify360 and ModSecurity blocked 12% of attacks against known-exploited vulnerabilities, and 26% on a broader test. The best host in the set blocked 60.7%. Several blocked under 17%. One blocked nothing.
So the hardening layer matters, and the failure mode that actually destroys it is not an attacker. It is a site owner who broke something once, could not tell what, and turned the whole thing off.
Step 1: a restore point that takes two commands
Do this before anything else. It is cheap enough that there is no argument against it.
cd /var/www/example.com/public_html
STAMP=$(date +%F-%H%M%S)
# The rewrite rules, which is what a path change actually writes.
cp -a .htaccess "/root/restore/htaccess.$STAMP" 2>/dev/null || true
# Options only. Full DB dumps are slow enough that people skip them, and
# every setting you are about to change lives in wp_options.
wp db export "/root/restore/options.$STAMP.sql" --tables=$(wp db prefix --allow-root)options
wp db prefix matters there because the prefix is not always wp_, and a restore aimed at the wrong table name fails at exactly the moment you are least able to debug it.
Step 2: the dead man's switch
This is the part worth stealing. Schedule a job that reverts everything in ten minutes, then cancel it once you have confirmed the site is fine. If you lock yourself out, you do nothing at all and the site comes back.
cat >/root/restore/revert.sh <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
SITE=/var/www/example.com/public_html
LATEST_HT=$(ls -1t /root/restore/htaccess.* 2>/dev/null | head -1)
[ -n "${LATEST_HT:-}" ] && cp -a "$LATEST_HT" "$SITE/.htaccess"
cd "$SITE"
# Substitute the slug of the plugin you are configuring.
wp plugin deactivate YOUR-PLUGIN-SLUG --allow-root || true
wp rewrite flush --hard --allow-root || true
logger -t wp-preflight "auto-revert fired"
EOF
chmod +x /root/restore/revert.sh
# Arm it. `at` is in most distro repos; install it if the command is missing.
echo "/root/restore/revert.sh" | at now + 10 minutes
atq # note the job number
Now go and make your change. When the site is confirmed healthy:
atrm <job-number> # disarm
If at is not available, a one-shot systemd timer does the same thing:
systemd-run --on-active=10min --unit=wp-preflight-revert /root/restore/revert.sh
# disarm with:
systemctl stop wp-preflight-revert.timer 2>/dev/null; systemctl reset-failed wp-preflight-revert.service
Ten minutes is the number I use because it is long enough to load a preset, purge caches and open the dashboard, and short enough that waiting it out is never worse than debugging under pressure. Re-arm it before each subsequent change rather than setting one long window.
Step 3: a watchdog in a second shell
Open another terminal before you touch anything and leave this running for the whole change window. It tells you the moment something stops answering, and it tells you which thing, which is the information the browser withholds while it sits on a spinner.
SITE="https://example.com"
LOGIN="/wp-login.php" # change to your new path after the switch
while true; do
printf '%s ' "$(date +%H:%M:%S)"
for U in "/" "$LOGIN" "/wp-admin/" "/wp-json/"; do
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 "$SITE$U")
printf '%s=%s ' "$U" "$code"
done
echo
sleep 5
done
Read the row, not a single number. Homepage 200 with the login path 404 is a path change that landed and a bookmark that did not, which is fine. Everything 200 except /wp-json/ is a REST route that did not get a matching rule, and it will show up later as saves failing silently. Homepage 500 is the rewrite file, and that is the case where you stop touching things and let the timer fire.
Step 4: WP-CLI is your out-of-band control plane
The reason a lockout feels catastrophic is that people assume the dashboard is the only way in. It is not, and knowing that in advance changes how much risk a path change actually carries.
WP-CLI does not go through HTTP. Rewrite rules are irrelevant to it. If the web-facing side of the site is unreachable, the command line still works completely.
wp plugin list --status=active --field=name # what is on
wp plugin deactivate YOUR-PLUGIN-SLUG # instant off, no dashboard needed
wp option get home; wp option get siteurl # confirm nothing rewrote these
wp rewrite flush --hard # regenerate rules, no settings changed
wp user list --role=administrator --fields=user_login,user_email
The FTP-era equivalent, for hosts with no shell, is renaming the plugin directory. WordPress deactivates a plugin whose folder has vanished, and it does it without touching your database.
mv wp-content/plugins/your-plugin-slug wp-content/plugins/your-plugin-slug.off
Both routes are worth testing once on a staging copy, because the first time you use a recovery procedure should not be the first time you read it.
Step 5: confirm the rules landed, not that you saved them
A saved setting and an applied rewrite rule are different objects. On Apache and LiteSpeed they usually coincide. On nginx they frequently do not, because plugins write .htaccess and nginx has never read .htaccess. The file gets written, the interface reports success, and the server carries on serving the old routes.
curl -sI "$SITE" | grep -i '^server:'
grep -c 'RewriteRule' .htaccess
# On nginx, the rules have to be in the server block instead:
sudo nginx -T 2>/dev/null | grep -n 'rewrite ' | head -20
sudo nginx -t && sudo systemctl reload nginx
nginx -t before every reload, without exception. A reload on a bad config is an outage, and an outage during a hardening window is the thing that convinces people hardening caused it.
Step 6: find and store the bypass route
Most plugins in this category generate a bypass parameter, a unique string that suspends path changes for a single request so you can reach the dashboard from outside your own configuration. It is generated at install time and it lives on a settings page, which is to say it lives behind the login form you may be about to move.
Look it up now, while you can still reach it, and put it in your password manager rather than a text file. Treat it as a credential: anyone holding it walks straight past your path security.
# Find where your plugin keeps its config, then read the relevant key.
wp option list --search='*YOURPREFIX*' --fields=option_name,option_value | head -40
If your plugin does not have one, its documented equivalents are the temporary pause from the plugins screen and the folder rename above. Know which you have before you need it.
The whole pre-flight
# 1. restore point
cp -a .htaccess /root/restore/htaccess.$(date +%F-%H%M%S)
wp db export /root/restore/options.$(date +%F-%H%M%S).sql --tables=$(wp db prefix)options
# 2. arm the dead man's switch
echo /root/restore/revert.sh | at now + 10 minutes
# 3. start the watchdog in a second shell
# 4. make exactly one change
# 5. confirm the row is green, then: atrm <job>
One change per window. That is the part people skip, and it is the part that turns a five second fix into an afternoon, because two simultaneous changes give you no way to attribute a failure to either of them.
None of this makes your configuration more secure. It makes being wrong cheap, and a hardening layer you are willing to touch is worth considerably more than a stronger one you are afraid to open.
What is in your pre-flight that is not in mine? I am particularly interested in anyone who has automated the cache purge into the same window, because that is the step I still do by hand and still occasionally forget.
Top comments (0)