WordPress is the most scanned platform on the web, so it's where automated attacks land first. Most of those attacks are unimaginative, and a disciplined configuration defeats them outright. This is the checklist we work from, with the server rules and a free, read-only script that checks a site against it.

1. Keep the code current, and keep less of it

Most WordPress compromises start with a known vulnerability in a plugin that was never updated. The fix is boring and effective: fewer plugins, updated quickly.

  • Update core, plugins, and themes on a schedule measured in days, not months.
  • Delete inactive plugins and themes. Deactivated code is still on disk and still reachable.
  • Keep one default theme as a fallback and remove the rest.
  • Review the plugin list every quarter. Every plugin is third-party code running with your database privileges.

Checked by the audit script: Core, plugin, and theme updates; inactive plugins and themes.

2. Control who gets in

Automated attacks guess passwords for known usernames at scale. Don't hand them the usernames, and make a correct guess useless.

  • No account named admin. It is the first username every brute-force tool tries.
  • As few administrators as possible. Most people only need the Editor role.
  • Two-factor authentication for every account that can publish or change settings.
  • Rate-limit the login endpoint at the server, the CDN, or with a plugin.
  • Stop leaking usernames: by default the REST API (/wp-json/wp/v2/users) and ?author=1 both reveal them.
  • Use a generic login error that doesn't confirm whether a username exists.
  • Keep open registration off unless you need it, and never with a privileged default role.

Checked by the audit script: The admin user, administrator count, REST user listing, author enumeration, registration settings.

3. Shrink the attack surface

Every endpoint and file an attacker can reach is something to probe. Remove what nobody needs.

  • Block xmlrpc.php unless Jetpack or the mobile app needs it. It lets attackers try many passwords in one request.
  • Turn off the built-in theme and plugin editor (DISALLOW_FILE_EDIT). A stolen admin session shouldn't come with a code editor.
  • Never execute PHP from wp-content/uploads.
  • No directory listings, and no public config backups, .env, .git, or debug.log files.
  • Don't advertise versions: remove the generator tag and X-Powered-By, and deny readme.html.

Checked by the audit script: All of the above.

4. HTTPS and security headers

Headers cost nothing and close whole classes of attacks in the browser.

  • HTTPS everywhere: redirect HTTP to HTTPS and force HTTPS for wp-admin.
  • Strict-Transport-Security, X-Content-Type-Options: nosniff, and a Referrer-Policy.
  • Clickjacking protection with X-Frame-Options or a CSP frame-ancestors rule.

Checked by the audit script: All of the above.

5. Production configuration

A few wp-config.php settings decide how much an attacker learns when something goes wrong.

  • WP_DEBUG off in production, and errors never displayed to visitors.
  • wp-config.php not world-readable (640, 600, or 440). It holds your database password.
  • A least-privilege database user that can't touch other databases or grant privileges.

Checked by the audit script: Debug settings and wp-config.php permissions.

6. Assume something gets through

No configuration is perfect. What matters is how fast you notice and how cleanly you recover.

  • File-integrity monitoring, so unexpected changes to core and plugin files are noticed.
  • Off-site backups, tested by actually restoring them.
  • An activity log for logins, role changes, and plugin installs.
  • A written plan for a compromise: who is called, how the site is taken offline, and how credentials and salts are rotated (wp config shuffle-salts).

These can't be verified by a script. Check them yourself.

Free audit script

wp-harden-audit.sh checks a site against this checklist, server-side through WP-CLI, externally through curl, or both. It never changes anything, and it exits with code 1 if any check fails, so it can gate a CI job or a cron alert.

curl -O https://westoakbridge.co/tools/wp-harden-audit.sh
chmod +x wp-harden-audit.sh

# External checks only
./wp-harden-audit.sh --url=https://example.com

# Full audit on the server (needs WP-CLI)
./wp-harden-audit.sh --path=/var/www/html --url=https://example.com
Accounts
  PASS  1 administrator account(s)
  FAIL  A user named "admin" exists
        ↳ The first username every brute-force tool tries — rename or replace it

External: https://example.com
  WARN  xmlrpc.php answers requests
  WARN  ?author=1 reveals a username (admin)

21 passed, 2 warnings, 1 failed

For the application-level fixes (username leaks, version tag, pingbacks, login errors, file editor), drop our must-use plugin into wp-content/mu-plugins/. Every block in it is independent, so delete the ones you don't want.

Download audit scriptDownload mu-plugin

Only run the external checks against sites you own or are authorized to test. A clean run means the configuration is sensible, not that the site is secure: it doesn't find vulnerable plugin code, malware, or problems on the hosting account.

Server rules and wp-config.php

Test on staging first. If something relies on XML-RPC, keep it and rate-limit it instead of blocking it.

Apache (.htaccess, above # BEGIN WordPress)

# WP Hardening Toolkit — Apache (2.4+) rules.
# Put in the site root .htaccess ABOVE the "# BEGIN WordPress" block.

# No directory listings anywhere.
Options -Indexes

# Block XML-RPC (remove this block if Jetpack or the mobile app needs it).
<Files "xmlrpc.php">
    Require all denied
</Files>

# Protect config, backups, logs, dotfiles, and fingerprinting files.
<FilesMatch "^(wp-config\.php|readme\.html|license\.txt|.*\.(bak|save|swp|old|orig|sql|log|ini)|.*~)$">
    Require all denied
</FilesMatch>
<FilesMatch "^\.">
    Require all denied
</FilesMatch>
RedirectMatch 404 /\.git

# Security headers (HSTS only once the whole site is on HTTPS).
<IfModule mod_headers.c>
    Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains"
    Header always set X-Content-Type-Options "nosniff"
    Header always set X-Frame-Options "SAMEORIGIN"
    Header always set Referrer-Policy "strict-origin-when-cross-origin"
    Header always set Permissions-Policy "camera=(), microphone=(), geolocation=()"
    Header unset X-Powered-By
</IfModule>

# ---------------------------------------------------------------------------
# Separate file: wp-content/uploads/.htaccess — never execute PHP from uploads.
#
#   <FilesMatch "\.(php|phtml|php[0-9]|phar)$">
#       Require all denied
#   </FilesMatch>

nginx (inside your server block)

# WP Hardening Toolkit — nginx rules.
# Include inside your WordPress `server { ... }` block, BEFORE the generic
# `location ~ \.php$` block so these matches win.

# Security headers (HSTS only once the whole site is on HTTPS).
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;

# No directory listings.
autoindex off;

# Block XML-RPC (remove if Jetpack or the mobile app needs it).
location = /xmlrpc.php { deny all; return 403; }

# Never execute PHP from uploads.
location ~* ^/wp-content/uploads/.*\.(php|phtml|php[0-9]|phar)$ { deny all; return 403; }

# Protect config, backups, logs, dotfiles, and fingerprinting files.
location = /wp-config.php { deny all; return 404; }
location ~* ^/(readme\.html|license\.txt)$ { deny all; return 404; }
location ~* \.(bak|save|swp|old|orig|sql|log|ini)$|~$ { deny all; return 404; }
location ~ /\. { deny all; return 404; }

# Also in php.ini / php-fpm pool:  expose_php = Off
# And in nginx http {}:            server_tokens off;

wp-config.php

<?php
/**
 * WP Hardening Toolkit — wp-config.php additions.
 * Paste above the line: / * That's all, stop editing! * /
 */

// No theme/plugin code editor in wp-admin (a stolen admin session shouldn't come with an IDE).
define( 'DISALLOW_FILE_EDIT', true );

// Optional, stricter: also blocks installing/updating plugins from wp-admin.
// Only if updates are handled by WP-CLI, CI, or your host.
// define( 'DISALLOW_FILE_MODS', true );

// Admin over HTTPS only.
define( 'FORCE_SSL_ADMIN', true );

// Never show errors to visitors in production.
define( 'WP_DEBUG', false );
define( 'WP_DEBUG_DISPLAY', false );
@ini_set( 'display_errors', '0' );

// Keep minor/security core updates automatic (the default — make it explicit).
define( 'WP_AUTO_UPDATE_CORE', 'minor' );

// Rotate the salts if the site was ever compromised:
//   wp config shuffle-salts