
If you’ve ever cleaned up a hacked WordPress site, there’s a good chance you found the malware sitting in wp-content/uploads. It’s the one directory that almost every WordPress install needs to be writable by the web server, which makes it the easiest place for an attacker to drop a file and the easiest place for that file to get missed. The fix is not exotic. It’s a handful of server rules that tell the web server to never execute PHP from that directory, no matter how it got there.
Why uploads is the favorite target
Most WordPress compromises don’t start with someone guessing your password. They start with a vulnerable plugin, an outdated theme, or a form upload field that doesn’t check file types strictly enough. The attacker’s goal in nearly all of these cases is the same: get a small PHP file, often called a web shell, onto the server somewhere the server will run it. Because wp-content/uploads is writable and reachable over HTTP, it’s the path of least resistance.
Once that shell is in place, an attacker doesn’t need your WordPress login at all. They hit the file directly, for example yoursite.com/wp-content/uploads/2023/05/x.php, and now they have code execution on your server, often with the same permissions as your site. From there they can inject spam links, redirect visitors, mine credentials, or use your server to attack others.
The uploads folder should only ever contain media: images, PDFs, videos, the occasional zip. It should never need to execute PHP. That’s the gap this guide closes.
The rule that neutralizes it
The fix is to tell your web server to refuse to execute any PHP file inside wp-content/uploads, regardless of what put it there. This does not stop a malicious file from being uploaded, but it stops it from ever running, which is what actually matters. A dead PHP file sitting in a folder is just inert text.
On Apache or LiteSpeed (which reads Apache-style .htaccess rules), add this to a .htaccess file inside wp-content/uploads:
<FilesMatch \.php$>
Require all denied
</FilesMatch>If your server is running an older Apache version that doesn’t support Require all denied, the equivalent legacy syntax is:
<Files *.php>
Order Allow,Deny
Deny from all
</Files>On Nginx, PHP execution is controlled centrally in the server block rather than per-directory .htaccess files, so you’ll add a location block to your site’s config:
location ~* /wp-content/uploads/.*\.php$ {
deny all;
}Place that block before your general PHP handler location so it takes precedence, then reload Nginx. If you’re on a managed host, check whether this rule already exists at the server level before adding your own. Hosts that specialize in WordPress, ServerBorn included, typically bake this exact denial into the server config by default, so it’s worth confirming with support rather than assuming you need to add it yourself.
Auditing what’s already there
Before you celebrate, check whether anything malicious is already sitting in uploads. From the command line at your site root, this find command lists every PHP file currently inside the uploads tree:
find wp-content/uploads -type f -iname "*.php"In a clean install, this should return nothing. If it returns anything, don’t assume it’s automatically bad, some very old or poorly built plugins genuinely do drop a PHP file in uploads for legitimate reasons, but treat every result as suspicious until you’ve confirmed what it does. A file with a generic name, garbled code, or heavy use of eval and base64_decode is a strong signal of compromise.
It’s also worth using wp-cli to verify the integrity of your core files and plugins, since a compromised uploads folder is often accompanied by tampered core or plugin files elsewhere:
wp core verify-checksums
wp plugin verify-checksums --allThese commands compare your installed files against the official checksums and flag anything that’s been modified. They won’t catch a brand new file that wasn’t part of the original package (like a shell dropped in uploads), which is exactly why the find command above matters as a companion check, not a substitute.
Testing you didn’t break anything
The overwhelming majority of WordPress sites have zero legitimate need for uploads to execute PHP, so this change is safe for nearly everyone. Still, test after applying it:
- Upload a new image through the Media Library and confirm it displays correctly on the front end.
- If you use a page builder, form plugin, or membership plugin that handles file uploads (resumes, documents, user avatars), test that specific upload flow.
- Check any plugin documentation that explicitly mentions writing PHP into uploads. This is rare and usually a sign of a poorly architected plugin, but it does exist in a small number of legacy tools.
If something breaks, the fix is almost never to remove the rule entirely. Instead, find the specific subfolder that needs an exception and scope the denial more narrowly, or better, ask the plugin author why they’re executing PHP from a media directory in the first place.
Layering additional defenses
Blocking PHP execution in uploads closes one specific door, but it works best alongside the rest of your hardening baseline:
- File permissions. Directories should generally be 755 and files 644, with no reason for uploads subfolders to be more permissive than that.
- DISALLOW_FILE_EDIT. Adding
define('DISALLOW_FILE_EDIT', true);towp-config.phpremoves the built-in theme and plugin file editor, closing another path attackers use once they have admin access. - Least-privilege user roles. Not every contributor needs Administrator. Fewer high-privilege accounts means fewer credentials worth stealing.
- Strong passwords and 2FA, limited login attempts. Most compromises still start with weak or reused credentials, so this remains foundational.
- A web application firewall. Cloudflare in front of your site adds a WAF layer, bot mitigation, and DDoS protection, and keeping your origin IP unpublished means attackers can’t simply bypass Cloudflare and hit your server directly.
- Tested, off-site backups. If something does get through, a backup you’ve actually verified you can restore from is what turns an incident into an inconvenience instead of a disaster.
Takeaway
Uploads has to stay writable, but it never needs to be executable. A short .htaccess or Nginx rule makes the entire directory tree inert to PHP, which quietly closes off the most common backdoor in WordPress without touching how media works for you or your visitors. Pair that with a quick audit for anything that’s already there, verify your core and plugin files with wp-cli, and fold it into the rest of your hardening routine. It’s a five-minute change that removes one of the easiest wins an attacker has.