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 theauto_update_plugin/auto_update_themefilters, 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.
2. Authentication hardening
Section titled “2. Authentication hardening”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 thewp-login.phpform, leavingxmlrpc.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 - disablingxmlrpc.phpentirely where the site doesn’t use it. - Don’t run the default
adminaccount. Rename it (or create a fresh admin and deleteadmin), 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.phptoo, not justwp-login.php, or the API path stays wide open. - Suppress the enumeration oracles. Disable the
?author=Nredirect 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.
3. Remove the editor write primitive
Section titled “3. Remove the editor write primitive”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_EDITdisables the in-dashboard theme and plugin editors (theme-editor.php,plugin-editor.php).DISALLOW_FILE_MODSgoes further and disables both editing and installing/uploading plugins and themes - the control that also closes the plugin-upload pathDISALLOW_FILE_EDITleaves 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/uploadswritable 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.”
wp-config.php hardening constants
Section titled “wp-config.php hardening constants”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 |
A note on security plugins
Section titled “A note on security plugins”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.phponly, 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.
For the report
Section titled “For the report”Lead with the control that closes the finding you demonstrated, then the lifecycle and containment layers:
- 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).
- Weak credentials / default admin → strong passwords, enforced 2FA, rename
admin, least-privilege roles. - Open user enumeration / brute-force → lockout on both
wp-login.phpandxmlrpc.php, suppress the?author=/REST oracles, homogenize login errors. - Admin-to-RCE →
DISALLOW_FILE_EDITas the default (keeps auto-updates working), orDISALLOW_FILE_MODSwhere patching is external (it also closes the plugin-upload path but disables the updater), plus read-only code directories and a writablewp-content/uploadswith script execution disabled across the whole subtree in server config (every PHP-mapped extension, not a writable.htaccess). - 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.
Related
Section titled “Related”- Version fingerprinting - the lifecycle failure these controls address, and its “Defensive note”
- Plugin & theme enumeration - the deactivation gap and the delete-don’t-deactivate remediation
- User enumeration - the oracles section 2 suppresses
- Login brute-force - the lockout-aware patterns section 2 defends against
- XML-RPC abuse - why lockout must cover the API path too
- Admin to RCE - the write primitive section 3 removes