What a WordPress security audit actually involves
A WordPress security audit is a manual review of the things attackers actually use: outdated plugins, weak or forgotten accounts, file permissions that let the web server rewrite its own code, and server settings that expose more than they should. A scanner is part of it. A scanner on its own is not an audit.
This walkthrough covers the checks worth running on a live business site, in the order that finds the most serious problems first. You need admin access to WordPress, access to the hosting control panel, and ideally SSH. Take a full backup of files and database before changing anything, and test permission changes on staging where one exists.
Why the same site needs auditing again six months later
WordPress sites are attacked constantly by automated tools that do not care how small the business is. A bot finds a plugin with a published vulnerability, tries the matching exploit, and moves on. Nothing about that process involves a human deciding your site is interesting.
The second reason is drift. Plugins get added during a marketing push and never removed. A developer is given an administrator account for a one week job. A hosting migration resets file ownership. None of those events feel like a security change at the time, and all of them can become one. A quarterly review catches most of it, with an extra pass after any significant change.
Plugins, themes and the published vulnerability record
Outdated plugins remain the way most WordPress sites are compromised. Start on the Plugins screen and treat any plugin showing an available update, an "untested with your version" warning, or no release in the last year as something that needs a decision.
Checking version numbers by eye is slow. WP-CLI gives you the whole picture in one line.
wp plugin list --fields=name,status,version,update,update_version
wp theme list --fields=name,status,version,update
Version numbers only tell you what is installed. To find out whether the installed version has a published flaw, cross-reference against a vulnerability database such as WPScan or Patchstack, both of which track WordPress plugin and theme advisories. A plugin two minor versions behind may be perfectly safe; another that is only one patch behind may be behind a known authenticated file upload bug.
Delete anything inactive. An inactive plugin still has its PHP files on disk, and several historical WordPress compromises came through files in plugins that were switched off in the dashboard. Deactivation is not removal.
For plugins that rarely break anything, turn on automatic updates rather than relying on someone logging in. WordPress has supported per-plugin auto-updates since version 5.5, and the trade-off between unattended updates and controlled ones is worth thinking through properly, which is covered in more depth in the guide to updating WordPress without breaking the site.
Core file integrity and the PHP version underneath
Modified core files are one of the clearest signs of a compromise, and WP-CLI can compare every core file against the official checksums published by WordPress.org.
wp core verify-checksums
wp plugin verify-checksums --all
Any file reported as modified in wp-includes or wp-admin deserves immediate attention. Legitimate changes to core files are rare and are almost always somebody's shortcut rather than an attack, but you cannot tell which without looking.
While you are there, check the PHP version the site runs on. Site Health under Tools reports it, or wp cli info from the shell. An unsupported PHP branch stops receiving security fixes, which means the language underneath your CMS is unpatched no matter how current the plugins are. The current support status for each branch is published on php.net, and WordPress lists its own recommended version in the official requirements page.
User accounts, roles and application passwords
List every account and its role before you look at anything else.
wp user list --fields=ID,user_login,user_email,roles,user_registered
Three things matter here. Accounts named admin or after the domain are the first guess in every credential stuffing run. Administrator accounts belonging to agencies, contractors or former staff should be removed or downgraded once the work is finished. And any account you cannot account for at all is a finding, not a curiosity.
Since WordPress 5.6 every user can also hold application passwords, which authenticate REST and XML-RPC requests without the login form. They survive a normal password reset, so an attacker who created one keeps access after you change the password. Review them on each administrator profile and revoke anything unfamiliar.
Two-factor authentication on administrator accounts is the single highest-value control on this list, because it removes the value of a stolen password entirely. The practical options for two-factor authentication on WordPress are worth setting up before you spend time on anything more exotic.
One more check worth thirty seconds: open /wp-json/wp/v2/users in a browser. If it returns a list of usernames, your login names are public, which turns a brute force attempt into a password-only guess. Several security plugins can restrict that endpoint to authenticated requests.
What to check inside wp-config.php
This file holds database credentials, the authentication salts, and the constants that decide how much WordPress will let an admin change at runtime. Four things are worth confirming on every audit.
- Salts and keys are real random strings. If they still hold the placeholder text from installation, anyone who knows those defaults can forge a valid logged-in cookie. Replace them from the official secret key generator, which logs every user out and is worth doing after any suspected compromise.
- Database credentials are unique to this site. Automated installers on shared hosting sometimes reuse predictable usernames across accounts.
- Debug output is off in production.
WP_DEBUGleft on can print file paths and query errors into the page, which hands an attacker a map of the installation. - The dashboard file editor is disabled. With
DISALLOW_FILE_EDITunset, one compromised administrator session is enough to write PHP straight into a theme.
define('DISALLOW_FILE_EDIT', true);
define('WP_DEBUG', false);
define('FORCE_SSL_ADMIN', true);
define('WP_ENVIRONMENT_TYPE', 'production');
Older guides also recommend FORCE_SSL_LOGIN. That constant was deprecated years ago and does nothing on a current release, because FORCE_SSL_ADMIN already covers the login form. Remove it if you find it.
On sites where all deployment happens through version control or a hosting pipeline, DISALLOW_FILE_MODS goes further and blocks plugin and theme installation entirely. That is a strong control on a locked-down site and an annoyance on one the client edits, so decide deliberately.
File ownership and permissions
Permissions are where a lot of WordPress advice is confidently wrong. The rule that matters is ownership, not just the numeric mode: files should be owned by the account that owns the site, and the PHP process should have as little write access as the site can function with.
Files at 644 and directories at 755 is the normal baseline. wp-config.php is often recommended at 600, and that works only when the PHP process runs as the file's owner, which is the usual arrangement with PHP-FPM pools. If PHP runs as a different user, 600 takes the site down with a database connection error, and the correct value is 640 with the group set to the PHP user.
stat -c "%a %U:%G %n" /var/www/example/wp-config.php
find /var/www/example -type f -perm /o=w -printf "%m %p\n"
The second command lists anything world-writable, which should return nothing on a healthy install.
Stopping PHP from executing in the uploads directory
The uploads directory has to be writable, which makes it the natural landing spot for a file upload vulnerability. Blocking execution there turns a successful upload into a harmless file.
On Apache, place this in wp-content/uploads/.htaccess.
<FilesMatch "\.(php|phtml|phar)$">
Require all denied
</FilesMatch>
On nginx, the equivalent goes in the server block, since nginx does not read .htaccess files at all.
location ~* /wp-content/uploads/.*\.(php|phtml|phar)$ {
deny all;
}
Note the Apache syntax. Order Deny,Allow is Apache 2.2 and has been superseded by Require since Apache 2.4, which is what every current Ubuntu and Debian release ships. Old snippets using the 2.2 form either error or silently do nothing depending on whether mod_access_compat happens to be loaded.
Hunting for backdoors and injected code
A backdoor is what an attacker leaves so that patching the original hole does not lock them out. They are usually small, usually obfuscated, and usually somewhere nobody looks.
find /var/www/example/wp-content/uploads -name "*.ph*" -type f
grep -rl --include="*.php" -E "eval\(|base64_decode\(|gzinflate\(|str_rot13\(" /var/www/example/wp-content/
find /var/www/example -name "*.php" -newermt "-14 days" -printf "%TY-%Tm-%Td %p\n" | sort
The first command is the highest-signal check on the list. WordPress never writes PHP into uploads, so anything it finds needs investigating rather than deleting on sight, because you want to know how it arrived. The third lists PHP files modified recently, which is how injected code in an otherwise normal theme tends to reveal itself.
Scanners such as Wordfence and Sucuri add signature matching on top of this and catch known families quickly. Neither replaces reading the unusual files yourself. If any of these checks find something, stop auditing and switch to the website malware cleanup process, because a live compromise needs containing before it needs documenting.
Reading the access log for attack patterns
The access log shows what is being tried against the site, successful or not. Four patterns are worth grepping for.
- POST requests to wp-login.php arriving faster than a person could type, usually from a small set of addresses.
- Traffic to xmlrpc.php, which allows many credential attempts inside a single request and is still a favourite for that reason.
- Long runs of 404s against plugin paths the site does not have, which is a scanner fingerprinting the installation.
- REST route probing under
/wp-json/, particularly against user and settings endpoints.
awk '$7 ~ /wp-login|xmlrpc/ {print $1}' /var/log/nginx/access.log \
| sort | uniq -c | sort -rn | head -20
If nothing on the site uses XML-RPC, and on most modern sites nothing does, block it at the server rather than with a plugin. On Apache 2.4 that is a <Files> block with Require all denied; on nginx, a location = /xmlrpc.php block with deny all. Check first whether the Jetpack connection or a mobile app depends on it.
HTTPS, mixed content and hardcoded URLs
Confirm the whole site is served over HTTPS and that no page pulls a script or stylesheet over plain HTTP. Mixed content weakens the page for the visitor and is trivial to spot in the browser console.
SELECT ID, post_title FROM wp_posts
WHERE post_status = 'publish' AND post_content LIKE '%src="http://%';
Fix hardcoded HTTP references at source rather than papering over them with a plugin that rewrites output on every request. A search and replace across the database, run with a tool that understands serialised data, is the safer route.
Server configuration around the site
Some of the strongest WordPress controls live outside WordPress. Directory listing off, dotfiles unreachable, sensible security headers, and no direct access to wp-config.php or backup archives left in the web root. The specifics for Apache are covered in the notes on Apache configuration settings, and the same principles map onto nginx server blocks.
Hosting matters here too. On oversubscribed shared plans you inherit the security posture of every other account on the box, which is one of several reasons cheap WordPress hosting can cost more than it saves.
The database prefix question
Plenty of older checklists tell you to change the wp_ table prefix. The honest assessment is that this offers very little. Any injection capable of reading your data can also read the prefix out of the schema, and changing it on a live site means rewriting option keys and user meta keys by hand, with a real chance of breaking plugins that stored the prefix somewhere.
On a brand new install, pick a different prefix and move on. On a site that is already live, spend the same hour on two-factor authentication and plugin updates instead, because those stop attacks that the prefix change does not.
Turning the audit into something that stays fixed
Write the findings down, fix the high-severity items first, and record the date. An audit that lives only in someone's memory gets repeated from scratch every time, and you lose the ability to see what changed between one review and the next.
The maintenance habits that keep the results in place are unglamorous: updates on a schedule, accounts reviewed when people leave, backups tested rather than assumed. The related notes on securing a WordPress installation cover the ongoing side of that work.
If you would rather have someone else run the audit and hand you a prioritised list, N. Cristea can review the site, the server configuration and the update history, and explain what each finding actually means for the business. Get in touch with the site address and a note on who currently has access.