Skip to content

Legacy WordPress reinfection: the .htaccess wall that stops it

A client called on Friday: the site we cleaned on Tuesday was serving pharma spam again. Three days, same infection. Here is why that loop runs until you wall off the writable directories.

Jacob Molkenboer
Jacob Molkenboer
Founder · A Brand New Company
Published
18 May 2026
Reading time
7 min read
Category
Security
Iron padlock clasped shut over a closed leather logbook on ivory paper, a thin green ribbon and cracked red wax seal beside it

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's functions.php.
  • A row in wp_options or wp_posts holding 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/ | sort

Treat 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.

Warning

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 -Indexes

The 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 installs

DISALLOW_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:

  1. Take a forensic copy of the whole account before you touch anything. You will want it.
  2. Rotate everything: hosting panel, SFTP, database user, every WordPress admin, and the secret keys in wp-config.php.
  3. Find and remove the backdoors. Verify core and plugin checksums; anything that fails and is not yours gets deleted, not edited.
  4. 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.
  5. Deploy the .htaccess wall and the wp-config.php flags.
  6. If you cannot fully trust the audit, redeploy core, plugins, and themes from clean sources and keep only the database and uploads, scanned.
  7. 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.

Frequently asked

Why does my WordPress site get reinfected after a professional cleanup?+
The cleanup removed the visible payload but left a backdoor and the original vulnerability. As long as one backdoor survives and the entry point is unpatched, automated botnets reinfect the site within minutes.
Does blocking PHP in wp-content/uploads break anything?+
No. WordPress only stores media there, never executable code. Just do not extend the block to all of wp-content, because legitimate plugins run PHP from their own folders and that will break the site.
Should I disable xmlrpc.php?+
Yes, unless a service you control needs it (older Jetpack features, some publishing apps). It is a common amplifier for brute-force and pingback abuse, and most modern sites never call it.
Will the .htaccess wall work on Nginx?+
No. Nginx ignores .htaccess entirely. You need equivalent location blocks in the server config to deny PHP execution in uploads and to lock sensitive files. The principle is identical; the syntax is not.

Want to build something similar?

Send us one paragraph about the process that eats the most of your week. We'll reply with an honest plan — within 4h on weekdays.