Most compromised WordPress sites are not broken through WordPress core, but through outdated plugins, weak or reused admin passwords and PHP files uploaded where they should never run. In this tutorial you will harden a WordPress site running on Nginx and PHP-FPM on Ubuntu 24.04: you will set up updates, fix file ownership and permissions, lock down wp-config.php, block dangerous paths in Nginx, rate limit and ban brute-force login attempts, add two-factor authentication, and check your files for tampering.
Prerequisites
To follow this tutorial, you will need:
- A server running Ubuntu 24.04 LTS, for example a CubePath VPS, with a non-root user that has
sudoprivileges and UFW enabled. - WordPress running on Nginx and PHP 8.3-FPM, installed in
/var/www/your_domainand served over HTTPS athttps://your_domain. - WP-CLI installed as
/usr/local/bin/wp. The WP-CLI commands below run aswww-datafrom/var/www/your_domain. - A recent backup of the database and files, so you can roll back any change.
Replace your_domain with your own domain throughout.
Step 1 - Updating WordPress, plugins and themes
Known vulnerabilities in plugins are the most common way into a WordPress site, so updates come first. Check what is outdated:
cd /var/www/your_domain
sudo -u www-data wp core check-update
sudo -u www-data wp plugin list --update=available
sudo -u www-data wp theme list --update=available
Update everything:
sudo -u www-data wp core update
sudo -u www-data wp plugin update --all
sudo -u www-data wp theme update --all
Remove plugins and themes you do not use. Deactivated code can still be reached directly over HTTP and exploited:
sudo -u www-data wp plugin list --status=inactive
sudo -u www-data wp plugin delete plugin-name
Keep one default theme, such as Twenty Twenty-Five, as a fallback and delete the other inactive themes.
WordPress installs minor core releases (security fixes) automatically. To also let plugins update themselves, enable auto-updates for all of them:
sudo -u www-data wp plugin auto-updates enable --all
Verify:
sudo -u www-data wp plugin auto-updates status --all
Step 2 - Setting file ownership and permissions
WordPress needs to write to wp-content for uploads and updates, but nothing should be writable by other users on the server. Give the files to www-data and apply standard permissions: 755 for directories and 644 for files:
sudo chown -R www-data:www-data /var/www/your_domain
sudo find /var/www/your_domain -type d -exec chmod 755 {} +
sudo find /var/www/your_domain -type f -exec chmod 644 {} +
wp-config.php contains the database password and secret keys, so only its owner and group should read it:
sudo chmod 640 /var/www/your_domain/wp-config.php
Check for anything that is still world-writable. This command should print nothing:
sudo find /var/www/your_domain -perm -o+w
WarningNever use
chmod 777to fix an upload or update error. It lets any process on the server modify your site. The error usually means a file has the wrong owner; run thechowncommand above again.
Step 3 - Hardening wp-config.php
A few constants in wp-config.php remove features attackers rely on. Open the file:
sudo nano /var/www/your_domain/wp-config.php
Add these lines above the comment /* That's all, stop editing! Happy publishing. */:
define( 'DISALLOW_FILE_EDIT', true );
define( 'FORCE_SSL_ADMIN', true );
define( 'WP_DEBUG', false );
DISALLOW_FILE_EDIT removes the theme and plugin file editors from the dashboard. Without it, anyone who steals an admin session can write PHP code into your site in a few clicks. If WP_DEBUG is already defined in the file, change its value instead of adding a second definition.
Replace the secret keys and salts. This logs out every user, including any attacker holding a stolen session cookie:
sudo -u www-data wp config shuffle-salts
Success: Shuffled the salt keys.
Check that PHP still loads the configuration without errors:
sudo -u www-data wp config list --fields=name --format=csv | grep -E 'DISALLOW_FILE_EDIT|FORCE_SSL_ADMIN'
DISALLOW_FILE_EDIT
FORCE_SSL_ADMIN
Step 4 - Securing administrator accounts
List the accounts with the administrator role:
sudo -u www-data wp user list --role=administrator --fields=ID,user_login,user_email
Remove any administrator you do not recognize, and lower the role of users who do not need full rights (editors and authors can publish content without installing plugins). If an account is named admin, create a new administrator with a different name, then delete the old one and give its content to the new account:
sudo -u www-data wp user create your_admin_user you@your_domain --role=administrator --prompt=user_pass
sudo -u www-data wp user delete admin --reassign=your_admin_user
With --prompt=user_pass, WP-CLI asks for the password instead of leaving it in your shell history. Use a long, unique password from a password manager.
Add two-factor authentication with the Two Factor plugin, maintained by WordPress contributors:
sudo -u www-data wp plugin install two-factor --activate
Each user then enables it in Users > Profile > Two-Factor Options, where they can pair an authenticator app (TOTP) and print backup codes. Enable it for every administrator account.
Step 5 - Blocking dangerous paths in Nginx
Several requests have no legitimate use on a typical site: executing PHP inside uploads, reading hidden files such as .git or .env, and the XML-RPC endpoint, which is a favorite for brute-force attacks because it accepts many password guesses per request. Open your site's server block:
sudo nano /etc/nginx/sites-available/your_domain
Add these rules inside the server block that listens on port 443, above your location ~ \.php$ block. Nginx uses the first matching regular expression location, so these must come first:
location ~* ^/wp-content/uploads/.*\.(php|phtml|phar)$ {
deny all;
}
location = /xmlrpc.php {
deny all;
access_log off;
log_not_found off;
}
location ~ /\.(?!well-known) {
deny all;
}
location ~* ^/(wp-config\.php|readme\.html|license\.txt)$ {
deny all;
}
NoteJetpack and the WordPress mobile app use XML-RPC. If you rely on either, remove the
xmlrpc.phpblock and rely on Fail2ban in Step 7 instead.
Add basic security headers in the same server block:
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 Strict-Transport-Security "max-age=31536000" always;
Only add Strict-Transport-Security once HTTPS works for the domain; browsers will refuse plain HTTP for a year afterwards. Test and reload Nginx:
sudo nginx -t
sudo systemctl reload nginx
Verify that PHP in uploads is blocked and XML-RPC is closed:
curl -s -o /dev/null -w '%{http_code}\n' https://your_domain/wp-content/uploads/test.php
curl -s -o /dev/null -w '%{http_code}\n' https://your_domain/xmlrpc.php
curl -sI https://your_domain/ | grep -i x-content-type-options
403
403
x-content-type-options: nosniff
Step 6 - Rate limiting the login page
Nginx can limit how often a single IP address posts to wp-login.php, which slows down password guessing before it reaches PHP. Define a shared memory zone in the http context:
sudo nano /etc/nginx/conf.d/wp-login-limit.conf
limit_req_zone $binary_remote_addr zone=wplogin:10m rate=6r/m;
In your site's 443 server block, add a location for the login page, above the general PHP location, that applies the limit and passes the request to PHP:
location = /wp-login.php {
limit_req zone=wplogin burst=5 nodelay;
limit_req_status 429;
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
}
This allows a burst of 5 requests and then one every 10 seconds per IP. Reload and test by sending 10 quick requests:
sudo nginx -t && sudo systemctl reload nginx
for i in $(seq 1 10); do curl -s -o /dev/null -w '%{http_code} ' https://your_domain/wp-login.php; done; echo
200 200 200 200 200 200 429 429 429 429
Step 7 - Banning brute-force attackers with Fail2ban
Rate limiting slows attackers down; Fail2ban blocks them at the firewall after repeated attempts. Install it:
sudo apt install fail2ban
Create a filter that matches login form submissions and XML-RPC calls in the Nginx access log:
sudo nano /etc/fail2ban/filter.d/wordpress-login.conf
[Definition]
failregex = ^<HOST> .* "POST /(wp-login\.php|xmlrpc\.php)
ignoreregex =
A successful login also sends a POST, but a real user does not log in more than five times in ten minutes. Create the jail:
sudo nano /etc/fail2ban/jail.d/wordpress.conf
[wordpress-login]
enabled = true
port = http,https
filter = wordpress-login
logpath = /var/log/nginx/access.log
backend = auto
maxretry = 5
findtime = 10m
bantime = 1h
Test the filter against your existing log, then restart Fail2ban:
sudo fail2ban-regex /var/log/nginx/access.log /etc/fail2ban/filter.d/wordpress-login.conf
sudo systemctl restart fail2ban
sudo fail2ban-client status wordpress-login
Status for the jail: wordpress-login
|- Filter
| |- Currently failed: 0
| |- Total failed: 0
| `- File list: /var/log/nginx/access.log
`- Actions
|- Currently banned: 0
|- Total banned: 0
`- Banned IP list:
If you lock yourself out, unban your address with sudo fail2ban-client set wordpress-login unbanip your_ip.
Step 8 - Checking files for tampering
WP-CLI can compare core and plugin files against the checksums published on WordPress.org. Any modified or extra file in core is a strong sign of compromise:
sudo -u www-data wp core verify-checksums
sudo -u www-data wp plugin verify-checksums --all
Success: WordPress installation verifies against checksums.
Success: Verified 8 of 8 plugins.
Premium plugins that are not on WordPress.org are reported as unverifiable, which is expected. Also look for PHP files in uploads, where there should be none:
sudo find /var/www/your_domain/wp-content/uploads -name '*.php'
Run these checks weekly, or after any suspicious activity. For continuous malware scanning and a WordPress-aware firewall, a plugin such as Wordfence adds a scanner and login alerts on top of the server-level measures in this guide.
Step 9 - Backing up the site
Hardening reduces the risk but does not remove it; a recent, off-server backup is what lets you recover. At minimum, export the database and archive the files regularly, and copy both to storage outside the server:
sudo -u www-data wp db export - | gzip > ~/db-$(date +%F).sql.gz
sudo tar -czf ~/files-$(date +%F).tar.gz -C /var/www your_domain
Automate this with a scheduled script or a backup plugin, and test a restore on a separate server from time to time.
Troubleshooting
- Updates ask for FTP credentials: WordPress cannot write to its own files. Run the
chowncommand from Step 2 again. - Images or media return 403 after Step 5: the uploads rule is catching more than PHP files. Make sure the regular expression ends in
\.(php|phtml|phar)$. - Login page shows "429 Too Many Requests" for real users: raise
rateorburstin thelimit_reqsettings, for examplerate=10r/m. - Fail2ban never bans: check that
sudo fail2ban-regexreports matches, and that the jail'slogpathis the log your site actually writes to if you use a per-site access log.
Conclusion
Your WordPress site now runs up-to-date code with correct permissions, a locked-down configuration, two-factor authentication for administrators, and Nginx and Fail2ban rules that stop the most common automated attacks. Next, schedule automated off-server backups, review your active plugins every few months, and add a Content Security Policy once you know which external scripts your theme loads.
