# WordPress Security and Hardening

> Five reversible hardening switches applied inside WordPress, the companion plugin that applies them, and how they fit together with the WAF rules in front.

Source: https://www.corepanel.net/docs/wordpress/security/
Last updated: 2026-08-12
Part of the CorePanel documentation — https://www.corepanel.net/docs

---

WordPress security in CorePanel works on two levels that complement each other:

- **Hardening** — changes applied *inside* WordPress: constants in `wp-config.php` and a
  companion must-use plugin.
- **The WAF** — rules applied *in front of* WordPress, blocking the request before PHP
  ever runs. See [Web Application Firewall](https://www.corepanel.net/docs/security/waf).

Hardening removes attack surface; the WAF stops attackers from reaching what is left.

## The hardening switches

Each is independent and reversible. **On always means "protection enabled"**, so
`xmlrpc = on` means XML-RPC is *blocked*.

| Switch | What it does | Default on new installs |
|---|---|---|
| **File editor** | Disables the plugin/theme editor in wp-admin (`DISALLOW_FILE_EDIT`) | **On** |
| **XML-RPC** | Blocks XML-RPC requests | **On** |
| **Version hiding** | Removes the WordPress version from the page source and feeds | **On** |
| **User enumeration** | Blocks `?author=N` and REST endpoints that list usernames | **On** |
| **Application passwords** | Disables WordPress application passwords | **Off** |

Set them per instance from the **Security** view, or:

```bash
corepanel wp hardening 12 3 --set xmlrpc=on --set user-enumeration=on
```

### Why these defaults

- **File editor off.** A stolen administrator session should not be a shell. This is the
  single highest-value switch and it costs a site nothing: plugin and theme files should
  come from an update or a deploy, not from a textarea in a browser.
- **XML-RPC blocked.** Modern WordPress uses the REST API. XML-RPC remains a favourite
  for password-guessing amplification (`system.multicall`) and pingback abuse. Turn it
  back on only if something you actually use needs it — some older mobile apps and the
  Jetpack connection do.
- **Version hidden.** Not a defence in itself, but it removes the free targeting hint.
- **User enumeration blocked.** Usernames are half of a credential-stuffing attempt.
  There is no reason for a public endpoint to hand out the login names of your
  administrators.
- **Application passwords left alone.** They are a legitimate integration mechanism, so
  disabling them is offered but not imposed.

> **Adopted sites start with everything off**
>
> A site CorePanel adopted by scanning reports every switch off until you enable them —
> adoption does not silently change how an existing site behaves. Reviewing the hardening
> of an imported site is a good first move after a migration.
## The companion plugin

The switches that live inside WordPress are applied by a must-use plugin CorePanel
installs at `wp-content/mu-plugins/corepanel.php`. Besides hardening, it produces the
one-time login links used by **Log in** in the panel, and it tells the server what may be
page-cached.

**The panel owns the file.** It is rewritten from scratch on every hardening or login
operation, so edits made by hand are lost at the next one. The supported extension points
are the filters below, not the file.

**Turning it off.** A must-use plugin cannot be deactivated from wp-admin, so there are
two escapes, both constants in `wp-config.php`:

| Constant | Effect |
|---|---|
| `define( 'COREPANEL_CACHE_OFF', true );` | Disables only the cache module. Hardening and login links keep working. |
| `define( 'COREPANEL_MU_OFF', true );` | Disables the companion entirely — the file returns before doing anything. |

Use the first if a theme misbehaves under caching, and the second only to rule the
companion out while diagnosing something else.

### The cache module

This section is the contract the module speaks; enabling, measuring and troubleshooting the
cache itself is in [Speed Optimizations](https://www.corepanel.net/docs/wordpress/speed).

It stores nothing. On each anonymous page view it decides whether the HTML just rendered
is the same HTML any other visitor without state would get, and declares that to the
server, which owns the actual cache. The module **does nothing at all unless the dynamic
cache is enabled for that domain** — with the cache off it reads one environment variable
that is not there and registers no hooks.

Pages it never declares cacheable: wp-admin, REST and AJAX, anything but a `GET`, logged-in
visitors, searches, previews, password-protected posts, visitors who have left a comment,
404s, the WooCommerce cart, checkout and account pages, and any request that filled a cart
or started a PHP session while rendering.

**Sites whose consent banner renders from the cookie.** A banner that decides *in PHP* what
the page contains — which tracking tags go in it, or a `cookies-accepted` class on `<body>` —
turns one URL into two different pages, and the cache stores one copy. Whoever loads it first
decides what every later visitor gets, and one of the two directions serves tracking tags to
people who refused them. The module recognises **Cookie Notice & Compliance** and **CookieYes
in its legacy mode**, and declares those sites uncacheable rather than get it wrong.

Most banners are not affected. **Complianz**, **Real Cookie Banner** and **CookieYes**
connected to its web app rewrite the page identically for every visitor and unblock in the
browser, so their HTML is shareable and they cache normally.

For any other banner — or a theme with its own `if ( $_COOKIE[…] )` — the check takes a
minute. Fetch the page twice, with and without the consent cookie, and compare:

```bash
curl -s 'https://example.com/?cache-check=1' > without.html
curl -s 'https://example.com/?cache-check=2' \
     -H 'Cookie: your-consent-cookie=yes' > with.html
diff without.html with.html
```

The throwaway parameter is what makes each request new to the cache, so both reach
WordPress and you compare what it renders rather than two copies of one stored page. A
request header asking not to cache would not do it — visitors do not get to empty a shared
cache, so the server ignores it.

Differences beyond nonces, timestamps and the parameter itself mean the page depends on
consent; use the `corepanel_cache_public` filter below to keep those pages out of the cache.

Whatever the module declares, the server keeps a veto of its own: a response that issues
a cookie is never stored, and neither is one whose own `Cache-Control` says `private` or
`no-store`. A page cache that trusted the application and nothing else would be one plugin
bug away from serving a logged-in visitor's page to everybody.

The same applies to `Vary`. A response declaring that it varies on something the cache does
not distinguish — `Vary: Cookie` is the common one, emitted indiscriminately by a number of
plugins — is not stored, because a single stored copy would then answer every visitor
whatever they sent. If a site caches far less than expected, an over-eager `Vary` from a
plugin is worth ruling out first.

Two filters let a theme or plugin override the decision:

```php
// Never cache this page.
add_filter( 'corepanel_cache_public', '__return_false' );

// Cache it, but for an hour instead of the default.
add_filter( 'corepanel_cache_max_age', function () { return HOUR_IN_SECONDS; } );
```

The declared lifetime is also capped automatically whenever the page mints a WordPress
nonce, so a cached page can never outlive the nonce inside it — a comment form that
silently stops accepting submissions is the classic symptom of a page cache that ignores
this, and it produces no error anywhere.

Publishing, editing, trashing or commenting on a post invalidates the URLs it affects —
the post itself, the front page, its category and tag archives, the feed and the sitemaps.
Changing the theme, the menus, the permalink structure or activating a plugin invalidates
the whole site.

> **One page cache at a time**
>
> If WP Rocket, LiteSpeed Cache, W3 Total Cache, WP Super Cache, WP Fastest Cache or Cache
> Enabler is active, the module stands down and declares nothing — two page caches on one
> site fight over the same output buffer. Remove the plugin cache to use CorePanel's.
## The WordPress WAF rules

In front of all this, the WAF ships a WordPress-specific rule family that stops attacks
before PHP runs:

| Rule | What it catches |
|---|---|
| `WPE-001` | `wp-login` brute force from non-browser clients |
| `WPE-002` | XML-RPC amplification (multicall / pingback) |
| `WPE-010` / `WPE-011` | User enumeration via `?author=N` and via the REST API |
| `WPE-020` / `WPE-022` | Access to sensitive config, backup and `debug.log` files |
| `WPE-030`–`WPE-032` | Path traversal in the URI, query and POST arguments |
| `WPE-040` / `WPE-041` | Executable uploads via double extensions and server control files |
| `WPE-050` / `WPE-051` | Comment spam and XSS in comment bodies |

The extended WordPress package (**WPE**) is available on **every edition**, including
Personal. It is off by default rather than on, because several of these rules touch flows a
plugin or a mobile app may legitimately use — turn it on under **WAF → Overview →
Protection packages**, in *detect* first if the site is busy. The core WordPress rules
(`WP-001`–`WP-004`) are part of the baseline protection and always active.

Hardening and WAF rules overlap on purpose — user enumeration, for example, is blocked
both inside WordPress and at the edge. Whichever layer sees the request first wins, and
neither depends on the other being enabled.

## After a compromise

If a site has been hacked, do these in order:

1. **Maintenance mode on** — stop serving the compromised site
   ([toggles](https://www.corepanel.net/docs/wordpress/managing#toggles)).
2. **Integrity check** — find modified core files.
3. **Reinstall core** — replace core files, keeping `wp-content` and `wp-config.php`.
4. **Review plugins and themes** — remove anything unused, update everything, and
   look hard at anything not from the official directory.
5. **Look through uploads** for PHP files. Nothing in `wp-content/uploads` should execute.
6. **Rotate credentials** — WordPress administrators, the database user, and the account's
   FTP password.
7. **Enable the hardening switches**, and check the WAF's event log for how they got in.
8. **Maintenance mode off.**

A [restore point](https://www.corepanel.net/docs/wordpress/managing#restore-points) or an
[account backup](https://www.corepanel.net/docs/backups) from before the compromise is faster than any of this —
if one exists, and if you are sure of the date.
