Hardening a WordPress Server in an Afternoon

WordPress LogoA default WordPress install is functional, not hardened. The software ships with sensible defaults for usability — which means some things that matter for security are either off, optional, or left to the server operator to configure. Most WordPress hardening guides are bloated with recommendations that are either irrelevant to self-hosted setups or solved problems in modern stacks. This is the version I’d hand to someone who runs their own server and has an afternoon to spend on it.

Fix file permissions first

WordPress needs to write to wp-content/uploads. It does not need to write to anything else at runtime. Tighten accordingly:

find /var/www/wordpress -type d -exec chmod 755 {} ;
find /var/www/wordpress -type f -exec chmod 644 {} ;
chmod 600 /var/www/wordpress/wp-config.php
chown -R www-data:www-data /var/www/wordpress

wp-config.php at 600 means only the web server user can read it. No world-readable database credentials.

If you’re not using the built-in theme/plugin editor (you shouldn’t be), you can also lock down wp-content/themes and wp-content/plugins so the web process can’t write to them. This pairs well with running a standalone theme — no parent theme means no files you don’t own:

chown -R root:www-data /var/www/wordpress/wp-content/themes
chown -R root:www-data /var/www/wordpress/wp-content/plugins
find /var/www/wordpress/wp-content/themes -type f -exec chmod 644 {} ;
find /var/www/wordpress/wp-content/plugins -type f -exec chmod 644 {} ;

Plugins and themes can only be updated via WP-CLI or by temporarily relaxing permissions — which is a feature, not a bug.

Harden wp-config.php

Add these constants if they’re not already present:

// Disable the file editor in wp-admin
define('DISALLOW_FILE_EDIT', true);

// Disable plugin/theme installation and updates via admin
define('DISALLOW_FILE_MODS', true);

// Force SSL for admin and logins
define('FORCE_SSL_ADMIN', true);

// Limit post revisions (default is unlimited)
define('WP_POST_REVISIONS', 5);

// Disable debug output in production
define('WP_DEBUG', false);
define('WP_DEBUG_LOG', false);
define('WP_DEBUG_DISPLAY', false);

DISALLOW_FILE_MODS is the strongest of these. If an attacker gets admin credentials, they can’t use the admin panel to write PHP to disk.

Also make sure your secret keys and salts are set and not the placeholder values. If you’ve never regenerated them, do it now — it invalidates all active sessions.

Disable XML-RPC

XML-RPC is a legacy remote publishing API. Unless you’re using the WordPress mobile app or Jetpack, you don’t need it. It’s a common brute-force target because it allows credential stuffing through a single endpoint.

In Apache, block it at the web server level:

<Files xmlrpc.php>
    Order Deny,Allow
    Deny from all
</Files>

Or in your virtual host config:

<Location /xmlrpc.php>
    Require all denied
</Location>

If you can’t touch the web server config, a filter in functions.php will disable it at the WordPress level:

add_filter('xmlrpc_enabled', '__return_false');

Block wp-admin by IP

If your admin access comes from a predictable IP range (home, office, VPN), restrict wp-admin at the web server before WordPress even loads:

<Directory /var/www/wordpress/wp-admin>
    Order Deny,Allow
    Deny from all
    Allow from 203.0.113.0/24
    Allow from 198.51.100.5
</Directory>

# wp-login.php lives outside wp-admin
<Files wp-login.php>
    Order Deny,Allow
    Deny from all
    Allow from 203.0.113.0/24
    Allow from 198.51.100.5
</Files>

Don’t forget wp-login.php — it lives outside the wp-admin directory and needs to be restricted separately.

Rate-limit and ban login attempts with fail2ban

Even with IP restrictions, you want fail2ban watching the auth log for credential stuffing against any exposed paths. Create a filter at /etc/fail2ban/filter.d/wordpress.conf:

[Definition]
failregex = ^<HOST> .* "POST /wp-login.php
ignoreregex =

And a jail in /etc/fail2ban/jail.local:

[wordpress]
enabled  = true
port     = http,https
filter   = wordpress
logpath  = /var/log/apache2/access.log
maxretry = 5
bantime  = 3600
findtime = 600

Adjust logpath to match your actual access log. Five attempts in ten minutes earns a one-hour ban.

Set security headers

Apache can send these headers on every response. Add them to your virtual host or a <Directory> block:

Header always set X-Frame-Options "SAMEORIGIN"
Header always set X-Content-Type-Options "nosniff"
Header always set Referrer-Policy "strict-origin-when-cross-origin"
Header always set Permissions-Policy "camera=(), microphone=(), geolocation=()"
Header always set Strict-Transport-Security "max-age=63072000; includeSubDomains; preload"

Make sure mod_headers is enabled: a2enmod headers.

The Content-Security-Policy header is conspicuously absent here. WordPress loads inline scripts and styles all over the place, and getting a correct CSP that doesn’t break admin or popular plugins takes dedicated work. Start with the headers above and tackle CSP separately when you’re ready to do it properly.

Disable directory listing

Apache’s default is to show a file listing when a directory has no index file. Make sure Options -Indexes is set in your WordPress Directory block:

<Directory /var/www/wordpress>
    Options -Indexes
    AllowOverride All
    Require all granted
</Directory>

Remove the WordPress version from output

WordPress adds a <meta name="generator"> tag and appends ?ver=X.X.X to enqueued assets. Neither is a serious vulnerability on its own, but there’s no reason to advertise the exact version to scanners:

// In functions.php
remove_action('wp_head', 'wp_generator');

add_filter('the_generator', '__return_empty_string');

// Strip version query strings from scripts and styles
function strip_asset_versions($src) {
    if (strpos($src, 'ver=')) {
        $src = remove_query_arg('ver', $src);
    }
    return $src;
}
add_filter('style_loader_src', 'strip_asset_versions');
add_filter('script_loader_src', 'strip_asset_versions');

Keep everything updated

Unpatched plugins are the leading cause of WordPress compromises, not configuration failures. Enable automatic background updates for minor WordPress releases (already on by default) and stay on top of plugin updates. If a plugin hasn’t had a release in two years and you’re not actively using it, remove it.

# Check for available updates via WP-CLI
wp --path=/var/www/wordpress core check-update
wp --path=/var/www/wordpress plugin list --update=available
wp --path=/var/www/wordpress theme list --update=available

Running this weekly and acting on it is worth more than any of the configuration steps above. If you’re managing WordPress with git, you also have a record of every file change — which makes it obvious if something unexpected gets modified on disk.

Check your Apache SSL cipher suite

If your server is more than a few years old, your Apache SSL configuration may still be advertising RC4 or other deprecated ciphers. Nothing breaks — modern clients just negotiate something better — but it’s silently there. Run:

grep -r "SSLCipherSuite|SSLProtocol" /etc/apache2/

If you see HIGH:MEDIUM without !RC4, or an explicit RC4 entry, or SSLProtocol all -SSLv3 (which still allows TLS 1.0 and 1.1), your cipher config needs updating. The full details and the correct replacement cipher suite are in Why Your Apache Cipher Suite Probably Has RC4 in It.

What this doesn’t cover

Database hardening (custom table prefix, restricted DB user permissions), two-factor authentication on wp-admin, and WAF rules are all worth doing but each deserves its own writeup. The steps above are the ones I’d do first on any new install — the high-value, low-effort baseline that closes the most common attack vectors before you move on to the more involved work.

← Converting a WordPress Child Theme to Standalone