Skip to content
System nominalUTC --:--:--Articles 124

Hardening After a Cleanup: Closing the Door They Came In

A cleaned site is not a hardened one. The controls that close the common entry points, the ones that break real sites, and the order to apply them.

Tomas Lindqvist · @tlindqvist

Hardening and operations

Topic
SEC
Section
Security & Hardening
Published
20 OCT 2026
Reading time
8 MIN
Words
1867W
Technical cover graphic for Hardening After a Cleanup: Closing the Door They Came In
Generated schematic — SEC series, REV 2.6.

The cleanup finished on a Wednesday. Core was reinstalled, the plugins were replaced, the administrator account the attacker had created was deleted, and the salts were rotated. By the following month the site had a security plugin, a firewall and a weekly scan, and the owner considered the matter closed. The door was still open. The plugin that had allowed the upload was three years without an update, still active, and now covered by three tools that watched it being used.

Hardening after an incident has a specific job: close the entry point that was used, then close the ones that would be used next. That is different from buying security. A control that does not map to a named way in is decoration, and decoration is how a site ends up with four overlapping plugins and the same abandoned upload form.

The controls below are ordered by how much they reduce risk per hour of work, not by how good they look on a dashboard. Each one names what it stops and what it costs.

Disclosure: some links on this page are affiliate links, and WPSifero may earn a commission if you buy through them. The checklist stands on its own; the paid tools at the end cover two items that are tedious to build yourself.

Map every hole to a control, or you are buying decoration

Most WordPress compromises enter through a short list of paths. An outdated plugin or theme with a known flaw. A weak or reused password, often on an account with no two-factor authentication. A stolen session cookie. An exposed configuration or backup file. A hosting or FTP account with more access than the site needs. Sometimes a malicious insider or a compromised developer machine, which is the same list with a different actor.

Entry pointControl that closes itHow to verify
Outdated plugin or themeUpdate policy, removal of abandoned codeUpdate list is empty or explained
Weak or reused passwordStrong passwords, two-factor, no shared loginsEvery administrator uses 2FA
Stolen sessionShort session life, salt rotation, login alertsSessions end when expected
Exposed config or backupDeny rules, files outside the web rootA request to the file returns 403
Over-privileged hosting accountSeparate accounts, least privilege, key-only SSHAccount list is short and current
Uploaded PHP shellNo PHP execution in uploadsRequest to a test PHP file is denied

The table is the plan. Work down it, and for each row write the verification, because a control that has not been tested is a setting somebody believes in. The rest of this article is the detail behind the rows that get skipped.

Remove access before adding defences

The cheapest hardening is deletion. Every administrator account is a login that can install code; every SSH key is a way in; every database user is a credential that can read customer data. Before adding a firewall, list them and remove what does not need to exist.

wp user list --role=administrator --fields=ID,user_login,user_email,user_registered
wp user list --role=editor --fields=ID,user_login,user_email
wp cap list administrator
wp plugin list --fields=name,status,version,update

Look for accounts that nobody recognises, accounts registered around the incident, and accounts that belong to former staff. Then check the hosting panel for FTP accounts, SSH keys and database users that were created for a migration and never removed. The panel's access list is usually longer than the site owner remembers.

Application passwords deserve their own pass. WordPress can issue per-application credentials that bypass the login form, and they are easy to forget. Revoke every one that is not actively used by a named integration, and prefer a dedicated user with the narrowest role that works for each integration.

Two roles are worth reviewing by hand. Administrators can install code, which is equivalent to running it on the server. Editors can publish, which on a site with unfiltered HTML is also a path to stored scripts. If an editor does not need unfiltered HTML, remove the capability rather than trusting the plugin that filters it.

Updates need a policy and a canary

"Keep everything updated" is not a policy; it is a sentence that fails the first busy week. A policy says what updates automatically, what waits for a staging check, and what happens to code nobody maintains. Without those three answers, the site alternates between months of neglect and a batch update at midnight before a launch.

A workable default for a small site: minor core updates automatically, major core updates after a staging check, security releases for plugins applied within days, and feature releases batched weekly. On a store, the batch runs on a staging copy first and includes a checkout test. The canary matters more than the schedule, because the update that breaks the site is usually the one applied without a look.

Then the abandoned code. A plugin with no update in two years is not automatically dangerous, but it is unmaintained, and unmaintained code accumulates known flaws. The decision rule is not age; it is whether the plugin is still supported and whether its job can be done by core or by a maintained alternative. Delete what is inactive. A deactivated plugin is still files on disk and still executable if a request reaches it.

Auto-updates are not a security strategy

They shorten the exposure window and they also apply breaking changes unattended. Pair them with a staging check for major versions and an off-host backup taken before the batch. A rollback plan is part of the update policy.

File permissions are a boundary, not a mood

WordPress needs to write to a small number of places: uploads, and sometimes the upgrade directory. It does not need to write to core, and it never needs to execute PHP from uploads. The permissions and the web server rules that enforce that are more durable than any plugin setting, because they survive a plugin being replaced.

The conventional ownership is the account user for files and the web server user for what must be writable, with directories at 755 and files at 644. The exact user depends on the host, and getting it wrong produces the upload errors described in the permissions article. The part that matters here is that wp-content/uploads is writable and wp-admin and wp-includes are not.

Then deny PHP execution in uploads. On Apache 2.4, an .htaccess in the uploads directory with the rule below stops a shell from running even if it is uploaded:

<FilesMatch "\.(?i:php|phtml|phar)$">
  Require all denied
</FilesMatch>

On nginx the equivalent is a location block that returns 403 for PHP under uploads. On a host that manages the web server, ask support to confirm the rule exists, because a file placed in the directory can be removed by an update or a migration and nobody will notice until it is needed.

Two constants finish the boundary. DISALLOW_FILE_EDIT removes the plugin and theme editor from the dashboard, which turns a stolen administrator session into a smaller problem. DISALLOW_FILE_MODS goes further and blocks installs and updates entirely, which is right for a site deployed from version control and wrong for one updated from the dashboard.

define( 'DISALLOW_FILE_EDIT', true );
// Only on a site where installs happen through a deploy pipeline.
// define( 'DISALLOW_FILE_MODS', true );

Detection you will read

Detection fails for a boring reason: the alert goes to an inbox nobody checks. A security plugin that sends a daily digest of blocked requests trains its owner to ignore it within two weeks. The controls worth running are the ones that produce a small number of actionable messages.

Four signals cover most of it. A login alert for administrators, including the IP and the time, because a login from a new country is worth a look. A file change alert for PHP files in uploads and must-use plugins, because those directories should not change. An uptime check with a real HTTP request, because the site being down is the incident you can still fix quickly. And a weekly scan result that names a file rather than a score.

The weekly review is where the value is. Open the administrator list, the cron list and the update list, and compare them with last week. Three lists, five minutes, and the anomalies are visible. Most sites that stay compromised for months are not missing a scanner; they are missing somebody who looks at the same three screens on a schedule.

The controls that break real sites

Every control has a cost, and the ones sold as hardening often hide it. Disabling XML-RPC breaks some mobile apps and can interfere with Jetpack's connection, which is why hosts that disable it blanket-wide cause support tickets. Blocking the REST API breaks the block editor, and blocking admin-ajax.php breaks half the plugins on the site. Blocking countries or IP ranges locks out customers and editors.

The pattern is the same in every case: a control applied at the wrong layer, too broadly. Disable XML-RPC only if nothing uses it, and test the Jetpack connection afterwards. Restrict REST API access by authentication and method, not by turning it off. Geo-block at the WAF with an exception path rather than at the web server with no exceptions.

The second cost is maintenance. A firewall with hand-written rules becomes a source of outages when the site changes and the rules do not. A security plugin that overlaps with the host's firewall produces duplicate blocks and confusing logs. Choose one layer for each job, write down which layer owns it, and remove the duplicate. A smaller stack that somebody understands beats a larger one that nobody can debug at two in the morning.

The order to apply it in

  1. Remove unused administrators, FTP accounts, SSH keys and database users.
  2. Turn on two-factor authentication for every administrator and for the hosting panel.
  3. Patch the entry point that was used, or remove the code that carried it.
  4. Set the update policy and run the first batch through staging.
  5. Deny PHP execution in uploads and confirm the rule with a test request.
  6. Set DISALLOW_FILE_EDIT and review file ownership and permissions.
  7. Configure the four alerts and schedule the weekly three-screen review.
  8. Take an off-host backup of the hardened state, so the next restore starts from here.

The paid layer is optional and narrow. Jetpack's scan module covers file monitoring and the restore point that pairs with it, which is the combination that turns a detection into a recovery. Akismet handles comment spam, which is a volume problem rather than an intrusion, but a site with an open comment form and no filter spends its attention on the wrong queue.

Where paid security is the wrong answer: a small brochure site with no logins beyond one administrator, on a host with a managed firewall and automatic core updates. There, the money is better spent on two-factor authentication, deleting the plugins nobody uses, and a backup that restores. The controls that close the door are mostly subtraction, and subtraction is free.

The decision rule is a sentence: for every way in, there is a named control and a test that proves it works. If a control cannot name what it stops, it is not hardening. It is a subscription.

Tomas Lindqvist · @tlindqvist

Hardening and operations

Threat models plugins, audits capability and nonce handling, and builds the observability that tells you an integration broke before a customer does.

Continue

Related in Security & Hardening