A 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.