Skip to content

Defending WordPress

WordPress defense is not one control - it is three habits applied continuously, because the platform’s attack surface is three different things wearing one logo. Core is patched fast and is rarely the way in; the third-party plugin and theme ecosystem is a weekly source of unauthenticated bugs; and the plugin/theme system is, by design, code execution as the web user, so any admin-equivalent foothold is RCE by default. The whole defensive game follows from that: manage the component lifecycle, harden authentication, and remove the editor’s write primitive. This page gathers the fixes, maps each to the attack it closes, and points at the cluster page where the mechanism lives.

1. Component lifecycle (closes the most common path)

Section titled “1. Component lifecycle (closes the most common path)”

The standard finding - “outdated plugin X = unauthenticated $impact” - is a patch-and-inventory failure, not a code bug the defender can fix. The controls:

  • Update core, plugins, and themes promptly. WP_AUTO_UPDATE_CORE (in the constants below) enables background updates for core only - plugin and theme auto-updates are off by default and must be turned on separately, per-item in the dashboard or via the auto_update_plugin / auto_update_theme filters, or replaced with an explicit patching process. Core auto-updates close the rare core CVE, but the plugin/theme updates are the ones that matter - that is where the CVEs land (vulnerable plugins) - so don’t let the core-only constant leave the component surface this section prioritizes unpatched.
  • Delete unused plugins and themes - don’t just deactivate them. Activation is only a database flag; the files stay on disk. Whether a deactivated plugin is still exploitable depends on the bug: a vuln in a directly-callable PHP script (hit by its file path, like the Mail Masta LFI) stays reachable because it bypasses WordPress’s bootstrap entirely, while a vuln that depends on the plugin’s registered hooks, AJAX, or REST handlers goes dormant once WordPress stops loading it. You can’t reliably tell which from the outside, so delete rather than deactivate. This is the deactivation gap, and deletion is the control that reliably closes it.
  • Minimize the installed set. Every plugin directory on disk is in scope. Fewer components, smaller surface, less to patch.
  • Track versions against an advisory feed. The same WPScan database an attacker uses is a defender’s inventory check: run it against your own estate and remediate what it flags.

Closes the second surface - user enumeration feeding login brute-force:

  • Strong, unique passwords and enforced 2FA on every account that can log in. A spray of Welcome2024! across a known user list is the cheapest path to admin; 2FA defeats it even when a password matches - but only on the paths the 2FA plugin actually covers. Many 2FA plugins gate only the wp-login.php form, leaving xmlrpc.php (and other API endpoints) accepting the raw password, so a guessed credential still works there. Verify 2FA applies to every authentication path, and where it can’t be enforced on an API, disable or restrict password-based access to it - disabling xmlrpc.php entirely where the site doesn’t use it.
  • Don’t run the default admin account. Rename it (or create a fresh admin and delete admin), so an enumerated user list doesn’t hand the attacker the administrator’s slug for free.
  • Least privilege by role. Subscribers and Authors shouldn’t hold powers they don’t need - the admin-to-RCE chain sometimes works from non-admin roles, so every unnecessary Editor is a liability.
  • Brute-force protection and lockout. A login-limiting plugin (the lockout behaviors described in login brute-force) raises the cost of both form and API attacks - but configure it on xmlrpc.php too, not just wp-login.php, or the API path stays wide open.
  • Suppress the enumeration oracles. Disable the ?author=N redirect and restrict /wp-json/wp/v2/users, and homogenize login error messages so “user doesn’t exist” and “wrong password” read identically. These don’t stop a determined attacker but remove the free, quiet user list that targets the brute-force.

Closes the third surface - authenticated admin-to-RCE. An admin who can edit theme/plugin files or upload a plugin ZIP has RCE by design, so the hardening is to take that write away:

  • DISALLOW_FILE_EDIT disables the in-dashboard theme and plugin editors (theme-editor.php, plugin-editor.php).
  • DISALLOW_FILE_MODS goes further and disables both editing and installing/uploading plugins and themes - the control that also closes the plugin-upload path DISALLOW_FILE_EDIT leaves open.
  • Keep the code directories read-only to the web user, so even a slip can’t drop a PHP file into the webroot. Don’t make every served directory unwritable, though - that breaks media uploads and, on direct-filesystem installs, the auto-updates from section 1. Separate the concerns instead: code read-only (updated out-of-band or by a different owner than the web user), and wp-content/uploads writable but with script execution disabled across the whole subtree. Set that in server configuration the web user can’t modify - not a writable .htaccess, which a planted file could overwrite to re-enable handlers - and cover every PHP-mapped extension (.php, .phtml, .phar, …), not just .php, so a planted file can’t run. See upload defenses for the directory-wide approach.

This is containment of a compromised admin, not a substitute for sections 1 and 2 - but on a hardened install it turns “admin password cracked” into “admin access without a straightforward shell.”

The constants worth setting in one place. The edit/mod constants are the mechanism behind section 3; the update constants drive section 1.

// --- Remove the editor write primitive (see Admin to RCE) ---
define( 'DISALLOW_FILE_EDIT', true ); // disables theme/plugin editors; leaves updates working
// Optional, and ONLY if you patch out-of-band (deploy from version control, no dashboard updates):
// define( 'DISALLOW_FILE_MODS', true ); // also blocks install/upload AND the background updater
// --- Automatic updates (see component lifecycle) ---
define( 'WP_AUTO_UPDATE_CORE', true ); // true = all core updates; 'minor' = security/minor only
// (Do NOT set AUTOMATIC_UPDATER_DISABLED = true - that turns updates OFF entirely)
// --- Transport ---
define( 'FORCE_SSL_ADMIN', true ); // force admin/login over HTTPS so session cookies aren't sent in cleartext
Constant Effect Closes
DISALLOW_FILE_EDIT Disables dashboard theme/plugin editors Theme/plugin editor webshell (admin-to-RCE)
DISALLOW_FILE_MODS Disables editors and install/upload — but also disables the background auto-updater, so only use it with an external patching process Editor webshell and malicious plugin ZIP upload
WP_AUTO_UPDATE_CORE Enables background core updates (true or 'minor') Unpatched-core CVEs (lifecycle)
FORCE_SSL_ADMIN Forces admin/login over HTTPS Cleartext session-cookie capture on the admin path

Wordfence, iThemes Security, and Sucuri bundle many of the above - lockout, enumeration suppression, fingerprint stripping, login renaming, file-change monitoring - into one install, and they’re widely deployed for good reason. Two caveats for the defender:

  • They are configuration, not magic. A security plugin with brute-force protection left on wp-login.php only, or with the REST/?author= oracles still open, closes less than its checkbox list suggests. Verify the behaviors (sections 1-3), don’t trust the badge.
  • They raise the cost of noisy attacks - the quiet, single-request paths (passive plugin enumeration, one spray attempt per user, a single vulnerable-plugin request) often slip under their thresholds. They are a layer, not the fix for an unpatched component.

Lead with the control that closes the finding you demonstrated, then the lifecycle and containment layers:

  1. Vulnerable/outdated plugin → update it, and delete unused plugins - deactivation still exposes directly-callable vulnerable scripts (hook/AJAX/REST-only bugs go dormant, but delete regardless since you can’t tell from outside).
  2. Weak credentials / default admin → strong passwords, enforced 2FA, rename admin, least-privilege roles.
  3. Open user enumeration / brute-force → lockout on both wp-login.php and xmlrpc.php, suppress the ?author=/REST oracles, homogenize login errors.
  4. Admin-to-RCE → DISALLOW_FILE_EDIT as the default (keeps auto-updates working), or DISALLOW_FILE_MODS where patching is external (it also closes the plugin-upload path but disables the updater), plus read-only code directories and a writable wp-content/uploads with script execution disabled across the whole subtree in server config (every PHP-mapped extension, not a writable .htaccess).
  5. Transport → FORCE_SSL_ADMIN + HTTPS everywhere.

Avoid presenting a single security plugin as the whole fix - this cluster exists because real installs ship with only some of these behaviors actually configured.