Short answer
Most WordPress compromises are not zero-days in core. They are a missing capability check, an unescaped output, an over-privileged role, or an abandoned plugin. Hardening is therefore mostly about code review discipline and dependency hygiene, not about installing a security plugin.
WordPress core has a mature security team and a good record. The attack surface that matters on an enterprise platform is the code your team wrote and the plugins someone installed in 2021 and forgot.
The four checks that catch most of it
1. Capability checks, not role checks
Checking current_user_can( 'edit_posts' ) asks the right question. Checking whether a user is an editor asks the wrong one — roles are configurable, capabilities are what the action actually requires.
Every AJAX handler, REST route and admin-post action needs one. A permission_callback of __return_true is correct for genuinely public read-only data and a vulnerability everywhere else:
register_rest_route( 'acme/v1', '/settings', array(
'methods' => WP_REST_Server::EDITABLE,
'permission_callback' => static function () {
return current_user_can( 'manage_options' );
},
'callback' => 'acme_update_settings',
) );
2. Nonces stop CSRF, not authorisation
A nonce proves the request came from your form. It says nothing about whether that user is allowed to perform the action. You need both, every time — check_admin_referer() or wp_verify_nonce() and a capability check.
3. Sanitise input, escape output, late
Sanitisation happens on the way in and depends on the data type. Escaping happens at the point of output and depends on the context — esc_html() in text, esc_attr() in an attribute, esc_url() in an href, wp_kses_post() where limited markup is allowed.
Escaping late, at the echo, is what makes it verifiable. A value escaped three functions earlier looks safe and cannot be checked in review without tracing the whole call path.
4. Prepared statements, always
// Interpolating a variable into SQL, even one that "must be" an integer.
$wpdb->get_results( "SELECT * FROM {$wpdb->posts} WHERE post_author = {$author}" );
// Prepared: the value can never be parsed as SQL.
$wpdb->get_results(
$wpdb->prepare( "SELECT * FROM {$wpdb->posts} WHERE post_author = %d", $author )
);
Note the table name is still interpolated — that is correct, $wpdb->posts is not user input. Only values are placeholders.
Dependency risk is the largest untracked surface
A typical WordPress install runs twenty to forty plugins, each with its own release cadence, maintainer and threat model. This is a supply chain, and it is rarely governed like one.
| Signal | What to check | Action if it fails |
|---|---|---|
| Maintenance | Last update, open issue backlog | Replace, fork or vendor the functionality |
| Necessity | Is it used on any live template? | Remove – unused plugins still execute |
| Privilege | Does it add roles or REST routes? | Audit those routes explicitly |
| Provenance | Installed from where, by whom? | Nightly-download plugins are unvetted code |
Deactivating a plugin does not remove its code from the filesystem, and a vulnerable file reachable over HTTP does not care whether the plugin is active. Delete rather than deactivate.
Configuration that is worth the ten minutes
DISALLOW_FILE_EDIT— removes the admin theme and plugin editors. An admin-account compromise otherwise becomes arbitrary code execution immediately.- Least-privilege roles — most people who “need admin” need
edit_pages. Every admin account is a full compromise if phished. - Secrets outside the repository — API keys in environment variables, never in
wp-config.phpcommitted to git and never in the options table where any admin-panel export exposes them. - Security headers —
Content-Security-Policy,X-Content-Type-Options: nosniff,Referrer-Policy, HSTS. CSP is the one with real effort attached and the one that most limits XSS impact. - Two-factor on every account with
edit_postsor above, not just administrators. - Upload hardening — never trust a client-supplied MIME type, and ensure PHP cannot execute from the uploads directory.
Make the review automatic
Discipline degrades; CI does not. Run PHPCS with the WordPress and WordPress-VIP-Go rulesets in the pipeline and fail the build on escaping, sanitisation, nonce and direct-query violations. Add PHPStan for the type errors that turn into runtime failures.
These are exactly the standards WordPress VIP enforces before deployment, and running them on a project that will never touch VIP still surfaces the patterns that break — see Engineering for WPVIP.
Assume a breach and plan for it
Prevention is not a complete strategy. Off-site, restore-tested backups; audit logging of privilege and content changes; a documented rotation path for every credential; and a known procedure for taking the site to read-only. The time to work out how to rotate a compromised API key is not during the incident.
The REST API surface deserves its own audit
Every custom post type registered with show_in_rest, every meta field registered as REST-visible, and every route added by a plugin is a public read endpoint unless something says otherwise. Enumerate what is actually exposed rather than assuming:
# Every registered namespace and route on the site.
wp eval 'foreach ( rest_get_server()->get_routes() as $route => $handlers ) { echo $route, PHP_EOL; }'
Two defaults worth changing on most installs. /wp-json/wp/v2/users enumerates author accounts, which is a username list for anyone attempting credential stuffing. And custom meta registered with show_in_rest => true for editor convenience is readable by anyone, which is a problem when that meta holds internal notes, pricing logic or a third-party record id.
File uploads
Uploads are the most common route from “authenticated contributor” to “arbitrary code execution”. Three controls do most of the work: validate the real file type server-side rather than trusting the client-supplied MIME type or the extension; never allow uploads of executable types, and treat SVG as executable because it can carry script; and ensure the web server cannot execute PHP from the uploads directory at all, so a file that slips through is inert.
Logging what matters
Detection is worth as much as prevention, because the question after an incident is always what changed and when. Log role and capability changes, user creation, plugin and theme activation, failed logins by IP, and edits to published content. Ship those logs somewhere the site itself cannot modify — an attacker with admin access can clear an audit log stored in the same database.
Questions people actually ask
- Do I need a security plugin?
- They are useful for monitoring, file-change detection and login rate limiting, and they do not substitute for correct code. A security plugin cannot fix a missing capability check in your own REST route. Treat it as detection, and put the prevention effort into review and CI.
- Is hiding the WordPress version or login URL worth doing?
- Marginally. It reduces opportunistic scanning noise and stops nothing targeted – the version is inferable from asset fingerprints regardless. Do it if it is free, but never count it as a control, and never let it substitute for patching.
- How do I handle secrets on WordPress?
- Environment variables read in
wp-config.php, sourced from your host’s secrets manager. Not in the repository, and not in the options table – options are exposed to anyone with admin access and to any plugin that exports settings. - What is the single highest-value change?
- Reducing the number of administrator accounts, followed by enforcing two-factor on the ones that remain. Most real compromises come through a legitimate credential rather than through a code vulnerability, and every admin account is a complete compromise.
- How often should plugins be updated?
- Security releases immediately, on a staging environment with a rollback path. Feature releases on a scheduled cadence. The risk of updating is a broken feature you will notice within hours; the risk of not updating is a published vulnerability with public exploit code.