WordPress Security Checklist: Fix Boring Things Before They Become Expensive
A practical WordPress security checklist covering users, passwords, updates, plugins, .htaccess, the REST API, XML-RPC, and WP-Cron.

Most WordPress compromises are not cool zero-days, and they are definitely not aimed at your site. Most are automated attacks sprayed across thousands of sites. Yours just happened to have a door open.
Software vulnerabilities and exploits are meticulously documented, scored, and sorted. Anyone with a brain cell can download a list of vulnerabilities for a WordPress version, plugin, theme, or CDN script, then let a bot check the internet for matching targets.
Don’t get me wrong: there are cool attacks involving social engineering, real zero-days, supply chains, domain takeovers, and many other fun terms. This article is not about those. This is about the mundane stuff that is easy to fix and boring to maintain, so it gets ignored: an old plugin, a reused password, an exposed endpoint, or a server configured with every door open because it was quicker on Friday.
This checklist is not a promise of invincibility. It is how you reduce the attack surface, notice trouble earlier, and make recovery much less painful.
Want to see what a bot sees first? The WordPress security check looks at your site from the outside, for the items on this list that are visible from the street: public usernames, XML-RPC, a visible version, HTTPS and headers. Everything else is homework.
The short version
If you only have 30 minutes, do these in order:
- Back up everything and make sure you know how to restore it.
- Review every account, remove unused ones, and pay close attention to admins.
- Give every user a unique password and enable 2FA for admins.
- Update WordPress, PHP, the active theme, and every plugin.
- Delete unused plugins and themes. Deactivated code is still code on your server.
- Install one security plugin and configure its alerts.
- Add activity and email logs so future mysteries leave evidence.
- Disable directory listing and protect sensitive files.
- Restrict REST API routes you do not need.
- Disable XML-RPC if nothing uses it.
- Limit or hide the login surface.
- Replace visitor-triggered WP-Cron with a real scheduled task.
Now let’s do each job properly.
1. Start with a backup
Before changing security settings, back up both the database and the files. A backup of only wp-content is not a complete site, and a database dump without the uploads is not one either.
Keep at least one copy away from the server. If the server is compromised, a backup stored on the same server may disappear with it.
Most importantly, know how to restore it. An untested backup is a hopeful-looking file, not a recovery plan.
If you can’t back up everything, for whatever reason, escalate the issue before you touch anything else. Don’t free-solo this site.
2. Check the users
Open Users → All Users and start with administrators.
- Delete old developer, agency, employee, and temporary accounts.
- Give each person their own account. Shared accounts make the activity log useless.
- Use the lowest role that can do the job. Most writers need Editor, not Administrator.
- Review Application Passwords on user profiles and revoke ones that no longer belong to a working integration.
- Change generic usernames such as
adminwhen practical. This reduces noise, but it does not replace a good password.
For every remaining administrator:
- Generate a long, unique password with a password manager.
- Never reuse a password from another site.
- Enable two-factor authentication.
- Make sure password-reset email goes to a mailbox that person still controls.
An attacker who got into the site can easily hide an admin account from this list. So if in doubt, skip the dashboard and ask the database:
# WP-CLI, with plugins and themes off so nothing can filter the listwp user list --skip-plugins --skip-themeswp db query "SELECT ID,user_login FROM wp_users"
# Or plain MySQL: credentials are in wp-config.php (or just use phpMyAdmin)mysql -h DB_HOST -u DB_USER -p DB_NAME -e "SELECT COUNT(*) FROM wp_users;"wp_ is the default table prefix; yours is $table_prefix in wp-config.php. If the count doesn’t match Users → All Users, somebody is hiding.
3. Update everything, then remove the leftovers
Update these together:
- WordPress core
- Plugins
- The active theme
- PHP
- Server packages, if you manage the server
Take a backup first, update on staging when the site earns money, and test the parts that matter afterwards: login, forms, checkout, email, search, and external integrations.
Delete plugins and themes you do not use. Inactive does not mean inaccessible. Their PHP files are still present under the public web root, and a vulnerable file may still be requested directly.
Keep one current default WordPress theme only if you want it as an emergency fallback. Delete the pile of old themes nobody remembers installing.
Do not keep abandoned software because replacing it is inconvenient. If a plugin is no longer maintained, the inconvenience has already arrived; it is merely waiting for a worse date.
4. Install a small, useful plugin stack
More security plugins do not mean more security. Two firewalls rewriting the same rules usually create confusion, duplicate alerts, and strange breakage. Pick one main security plugin, learn what it does, and configure it.
One security plugin
Wordfence is a reasonable general-purpose option. It provides a firewall, malware and file-integrity scanning, login controls, two-factor authentication, and security alerts.
After installing it:
- Run the first scan and investigate the results.
- Enable two-factor authentication for administrators.
- Configure brute-force protection.
- Send alerts to a mailbox somebody actually reads.
- Avoid enabling every aggressive option at once. Change a few settings, then test the site.
If your host already provides a managed WordPress firewall and malware scanner, understand what it covers before adding another heavy plugin.
WP 2FA
WP 2FA adds email-based 2FA, for when Wordfence’s authenticator-app codes (TOTP) are too much for your users. You could build this yourself in a few hours, but it’s more convenient to have one that somebody else configures and maintains.
WP Activity Log
WP Activity Log records useful events such as logins, user changes, plugin installations, settings changes, and content edits.
The log answers the question security incidents always produce: “Who changed this, and when?” Configure retention so the database does not grow forever, and restrict log access to people who genuinely need it.
WP Mail Logging
WP Mail Logging records messages WordPress tried to send. It is useful when password resets, contact forms, or order emails vanish into the fog.
Email logs may contain names, addresses, message bodies, and password-reset links. Limit who can read them, keep them only as long as useful, and do not treat logging as proof of delivery. It proves WordPress attempted to send the message. Whether it arrives is up to SPF, DKIM and DMARC.
Not security, but while you’re in there
WP Rocket (caching) and WebP Express (smaller images) won’t stop a single attacker, but WP Rocket is the thing you reach for on nearly every install anyway. Grab disable cache for admins too if you plan to test in production. And since WebP Express adds rewrite rules, check your images through the cache/CDN in a private window before declaring victory.
5. Harden Apache without breaking WordPress
This section is for Apache or LiteSpeed servers that support .htaccess. Nginx does not read this file; make equivalent rules in the Nginx server configuration instead.
Back up the existing .htaccess first. Put custom rules above # BEGIN WordPress. WordPress may replace anything between its begin and end markers.
Add this to the .htaccess in the WordPress root:
# Do not show a file list when a directory has no index file.Options -Indexes
# Block files that should never be downloaded through the web server.<FilesMatch "(?i)^(wp-config\.php|\.htaccess|\.htpasswd|debug\.log)$"> Require all denied</FilesMatch>
# Block common backup, configuration, log, and database-dump files.<FilesMatch "(?i)\.(bak|conf|dist|ini|log|sh|sql|swp)$"> Require all denied</FilesMatch>Then create a separate .htaccess inside wp-content/uploads/:
# Uploaded files must not execute as PHP.<FilesMatch "(?i)\.(php[0-9]?|phtml|phar|phps)$"> Require all denied</FilesMatch>apache › 403. The backup stays a backup, not a download.
Test the homepage, admin area, media library, forms, and image processing after adding rules. A 500 Internal Server Error usually means the host does not permit one of these directives. Restore the backup and ask the host for the server-level equivalent.
Also add this to wp-config.php to remove the built-in plugin and theme code editor from the dashboard:
define('DISALLOW_FILE_EDIT', true);It will not stop a compromised administrator from installing a malicious plugin, but it removes one easy way to edit executable files.
6. Restrict the REST API carefully
Visit these URLs while logged out:
https://example.com/wp-json/https://example.com/wp-json/wp/v2/usersThe first lists every registered REST route. The second may hand out author names and slugs. Not a password leak, but it saves the password-guessing bots half the work.
Don’t nuke all of /wp-json/ in response, though. The block editor runs on it, and so do WooCommerce, Contact Form 7, Jetpack, and most headless setups.
For a simple brochure site with no public API consumers, a maintained security plugin can require authentication for anonymous REST requests. For a more complex site, allow the public routes the frontend actually uses and require authentication for the rest.
After changing the policy, test while logged out and logged in:
- Pages and posts
- Contact forms
- Search
- Checkout and account pages
- The block editor
- Mobile apps, webhooks, and external integrations
The goal is not “make /wp-json/ return an error.” The goal is “expose only the routes the site needs.” WordPress itself recommends against disabling the REST API completely.
7. Disable XML-RPC when nothing needs it
XML-RPC is the remote API WordPress had before the REST API. Today it mostly serves Jetpack, some mobile-app workflows, pingbacks, and brute-force bots that like it because one request can carry hundreds of password guesses. If you use none of the legitimate ones, block it at the web server so WordPress never even wakes up for it.
xmlrpc › 500 guesses, one request. The limiter counted 1.
Add this to the root .htaccess, above the WordPress markers:
<Files "xmlrpc.php"> Require all denied</Files>Then visit https://example.com/xmlrpc.php. You should receive 403 Forbidden, not the usual “XML-RPC server accepts POST requests only” message.
If Jetpack or another integration stops working, restore access and use the security plugin to disable only XML-RPC authentication or pingbacks instead.
8. Limit or hide the login surface
Blocking XML-RPC, adding 2FA and changing the login URL are all good practice. But the best fix is simpler: let only known IPs reach wp-login.php and /wp-admin/, with an IP allowlist or Tailscale. This way, even if an attacker knows your credentials or has your session cookie, they cannot use them to log in or change the site.
It’s quite easy to do. On Apache it’s a few Require ip lines, there are plenty of guides, and I’ll probably add one in the future. Just leave /wp-admin/admin-ajax.php open: forms, carts and search on the public site often call it.
9. Replace WP-Cron; do not merely turn it off
WP-Cron is not actually cron. WordPress checks for due tasks whenever somebody loads a page, so on a quiet site your scheduled post goes out whenever the next visitor wanders in, and on a busy one it’s an extra check bolted onto every request. Hand the job to the server’s real scheduler.
wp-cron › Post due 09:00, out at 11:42. Blame the first visitor.
First add this to wp-config.php:
define('DISABLE_WP_CRON', true);Then run due events every five minutes with a real cron job. If WP-CLI is installed, this is the cleanest version:
*/5 * * * * cd /path/to/wordpress && /usr/local/bin/wp cron event run --due-now --quietUse the correct WordPress path and WP-CLI path for your server, and run the job as the site owner rather than root. Most hosting panels have a Cron Jobs or Scheduled Tasks screen where you can add the same command. To check when a schedule actually fires before you trust it, paste it into the cron explainer.
Only after that job works should you block public access to wp-cron.php:
<Files "wp-cron.php"> Require all denied</Files>Check it with:
wp cron event listIf scheduled posts, backups, cleanup jobs, or WooCommerce actions quietly stop running, your replacement cron is not working. Remove DISABLE_WP_CRON until it is fixed. Broken cron doesn’t throw errors; it just stops doing things, and you notice weeks later. The WordPress plugin handbook describes the same replacement process.
Keep it maintained
Security is mostly maintenance with better consequences.
Every week: review critical updates and security alerts, then confirm backups completed.
Every month: update everything, review administrators and application passwords, check the activity log, and delete unused code.
Every few months: restore a backup to a safe location and test it. Check that forms, email, cron jobs, and external integrations still work.
If the site is compromised, do not just delete the suspicious file and move on. Preserve the logs, change credentials, find the entry point, patch it, look for persistence, and restore from a known-good backup when necessary.
Useful references
- WordPress hardening guide
- WordPress REST API handbook
- Apache and
.htaccessguidance for WordPress - WordPress file permissions
Cover: Antique padlocks, Treasures in the Wall Museum, Akko by Adam Jones, CC BY-SA 2.0.
Comments
Comments live in GitHub Discussions (via giscus), so you need a GitHub account to join in.