A client called on a Friday afternoon. The WordPress site we had cleaned on Tuesday was serving pharma spam again. Same injected <head>, same hosting account, same junk URLs sitting in Google's index. Three days of clean, then reinfected and back to where we started.
This is the most common security call we get on legacy WordPress. Not "we got hacked," but "we got hacked again, after we paid someone to fix it." The reinfection is not bad luck. It means the last cleanup removed the symptom and left the mechanism running.
Why a cleaned site reinfects
A malware scan removes what you can see: the spam links, the injected JavaScript, the rewritten index.php. It rarely removes the backdoor. A backdoor is a small PHP file, or a few lines bolted onto a legitimate one, that lets an attacker write to your site whenever they want. As long as one backdoor survives, and the hole that let them in is still open, reinfection is automated. A botnet already has your domain on a list. It comes back in minutes, not weeks.
The payload you cleaned was never the point. The point is persistence, and persistence usually hides in one of these places:
- A PHP file dropped into
wp-content/uploads, a directory where WordPress only ever expects images and PDFs. - A fake must-use plugin in
wp-content/mu-plugins, which loads on every request and never appears on the Plugins screen. - A few appended lines in
wp-config.php,wp-load.php, or a theme'sfunctions.php. - A row in
wp_optionsorwp_postsholding a base64 blob that a backdoor re-reads on demand.
And the boring one: a second, neglected site in the same hosting account. Shared document roots reinfect each other. Cleaning one site and ignoring the abandoned staging copy next to it is mopping half the floor.
Find the mechanism before you build the wall
The wall does not work if you skip this step. Lock the doors with a known intruder still inside and you have just trapped them with your data.
Start by comparing the install against pristine WordPress. WP-CLI does this in two commands and is the fastest way to see what does not belong:
# From the WordPress root, with WP-CLI installed
wp core verify-checksums
wp plugin verify-checksums --all
# PHP files changed since you went live this month
find . -type f -name "*.php" -newermt "2026-05-01" -print | sort
# Common backdoor signatures (leads, not proof)
grep -RIlE "eval\(|base64_decode\(|gzinflate\(|str_rot13\(" \
--include="*.php" wp-content/ | sortTreat the grep output as leads, not a verdict. Modern backdoors split eval across string concatenation specifically to defeat that pattern. The checksum diff and the "changed recently" list are more reliable, because they describe state, not intent.
The .htaccess rules below only apply on Apache (or LiteSpeed, which reads the same files). On Nginx they do nothing and you need the equivalent location blocks. And no .htaccess rule cleans an infected site. It stops the next infection from executing. You still have to find and delete the backdoor first.
The .htaccess wall
The mental shift that ends the loop: assume a malicious PHP file will land in a writable directory again, and remove its ability to run when it does. An upload folder full of .php files is harmless if the server refuses to execute PHP from there.
Layer one goes in the uploads directory itself. Create wp-content/uploads/.htaccess:
# wp-content/uploads/.htaccess (Apache 2.4+)
<FilesMatch "\.(?i:php|phtml|php3|php4|php5|php7|phps|pht|inc)$">
Require all denied
</FilesMatch>Do not blanket-disable PHP across all of wp-content. Plugins legitimately execute code from their own folders, and you will break the site. The uploads tree is the safe place to be absolute, because nothing there should ever run.
Layer two goes in the root .htaccess, above the # BEGIN WordPress block so WordPress does not overwrite it:
# Lock files attackers read or write
<FilesMatch "^(wp-config\.php|\.htaccess|\.user\.ini|readme\.html|license\.txt)$">
Require all denied
</FilesMatch>
# Disable XML-RPC unless a service you control needs it
<Files xmlrpc.php>
Require all denied
</Files>
# No directory listings
Options -IndexesThe FilesMatch directive is documented in the Apache core module reference; it matches by filename regardless of the request path, which is exactly what you want for files that should never be fetched directly.
The XML-RPC block is having a moment in the WordPress community again, and for good reason. xmlrpc.php is the classic amplifier for credential-stuffing and pingback abuse: one request, hundreds of password guesses. Unless you run the Jetpack app or a publishing client that genuinely depends on it, turning it off removes a whole category of noise from your logs and your CPU graph.
Close the editor too
The wall is filesystem-level. Add one application-level rule while you are in there, because a compromised admin account using the built-in theme editor is a backdoor with a UI. In wp-config.php:
// wp-config.php, above the "stop editing" line
define( 'DISALLOW_FILE_EDIT', true );
define( 'DISALLOW_FILE_MODS', true ); // also blocks plugin/theme installsDISALLOW_FILE_MODS is aggressive: it blocks updates and installs through the dashboard, so only use it on sites you deploy to from somewhere else. The first line, though, belongs on almost every production install. The official WordPress hardening guide has been recommending it for years, and it costs nothing.
The order that actually stops the loop
Sequence matters more than any single rule. We run reinfection cases in this order, every time:
- Take a forensic copy of the whole account before you touch anything. You will want it.
- Rotate everything: hosting panel, SFTP, database user, every WordPress admin, and the secret keys in
wp-config.php. - Find and remove the backdoors. Verify core and plugin checksums; anything that fails and is not yours gets deleted, not edited.
- Patch the entry point. It is almost always an outdated plugin or a long-abandoned premium theme. Update it, or rip it out if the vendor is gone.
- Deploy the .htaccess wall and the
wp-config.phpflags. - If you cannot fully trust the audit, redeploy core, plugins, and themes from clean sources and keep only the database and uploads, scanned.
- Watch the logs for 7 to 14 days. Reinfection that gets through shows up fast; silence past two weeks usually means you got it.
Steps four and five are the ones the cheap cleanups skip. The wall without the patch buys you a few quiet days. The patch without the wall holds until the next zero-day in the next forgotten plugin.
When we took over a legacy WordPress rescue for a Dutch logistics client last year, the reinfection turned out to be a mu-plugin that three previous cleanups had never opened, loading on every request and rewriting two files a day. The .htaccess wall held the line while we found it and rebuilt the entry point properly.
The five-minute version you can run today: SSH into the account, run wp core verify-checksums, then list every PHP file under wp-content/uploads. If either command returns anything, you already have your answer about why it keeps coming back.




