Technology

WordPress Hardening Checklist: The Friendly Way to Lock Down File Permissions, Salt Keys, XML-RPC, and UFW/Fail2ban

The Morning I Realized My WordPress Needed a Bodyguard

I still remember the morning a client pinged me with the kind of message that makes your coffee suddenly taste like battery acid: “Our site is slow, weird logins, and I can’t save posts.” We’d all been there—plugin updates that went sideways, login spam that looked like a tidal wave, and some mystery process chewing CPU like it was popcorn. The culprit, as it turned out, wasn’t just one thing. It was a combination of soft underbelly settings: loose file permissions, stale salt keys, a wide-open XML-RPC endpoint, and a server without a proper firewall bouncer at the door.

Hardening WordPress isn’t about making it awkward to work with. It’s more like setting house rules: where the keys live, which doors stay locked, who’s allowed to knock, and what happens if someone keeps hammering the bell. In this guide, I’ll walk you through a warm, practical checklist that starts with file permissions that actually make sense, rotates those secret salt keys that quietly protect your sessions, puts XML-RPC into context (and a leash), and finishes with a one-two combo of UFW and Fail2ban to keep the riffraff outside.

If you’ve ever had that moment where you thought “I’ll lock this down tomorrow,” think of this as your friendly nudge. We’ll go step by step, with concrete examples you can copy, and a few stories from the trenches so it feels less like homework and more like tidying up your digital living room before guests arrive.

Before We Tighten Bolts: A Calm Baseline

Here’s the thing about security: it’s easier when your basics are already peaceful. I like to start by making sure backups exist and are verified, staging works, and we know who owns the files on disk. When that foundation is in place, every hardening step feels like a calm upgrade rather than a gamble.

Two quick things make a big difference. First, if you manage your WordPress on a VPS, keep your SSH access in good shape—keys instead of passwords, maybe a hardware key if you can swing it, and a plan for rotating them. I shared a step-by-step I use myself in VPS SSH Hardening Without the Drama. Second, know how you’ll watch your logs so you can catch patterns early. If you’re curious, my playbook for clean, central logs is in VPS Log Management Without the Drama. You don’t need the whole observability orchestra today—just enough to hear when someone’s playing the wrong notes.

Okay, baseline done. Let’s tighten some bolts.

File Permissions That Don’t Fight You (or Invite Strangers)

When I first started hosting WordPress for clients, I saw all sorts of “creative” file permissions. Someone, somewhere, had told them that 777 is a magic fix. It is magic, just not the good kind. The safer truth is simple: the web server should be able to read what it serves, write only where it must, and nothing more.

Who owns what—and why it matters

On a typical Linux server with Nginx or Apache, your web server user might be www-data, apache, or nginx. Ideally, the files live under a user account you control (say deploy or wpuser), with the web server group granted the right access. This gives you a clean boundary: you can deploy and edit files as yourself, while the server only gets exactly what it needs at runtime.

In practice, that means setting ownership like this (adjust paths and users to your world):

cd /var/www/example.com
sudo chown -R wpuser:www-data .

Now, about permissions.

The tidy defaults that just work

Most WordPress files can safely be 644 (readable by all, writable by owner) and directories 755 (enterable by all, writable by owner). That balance lets the web server read your site while keeping write access limited. So, a simple sweep like this goes a long way:

find /var/www/example.com -type d -exec chmod 755 {} ;
find /var/www/example.com -type f -exec chmod 644 {} ;

There are two special cases. First, wp-config.php holds your secrets, so give it extra love:

chmod 640 /var/www/example.com/wp-config.php

Second, wherever PHP needs to write—usually wp-content/uploads, sometimes wp-content/cache—you can grant group write permission by setting directories to 775 and files to 664. That way, the server can save uploads without giving away the keys to the entire kingdom.

find /var/www/example.com/wp-content/uploads -type d -exec chmod 775 {} ;
find /var/www/example.com/wp-content/uploads -type f -exec chmod 664 {} ;

In my experience, most “my site got hacked” stories involve a weak link: a vulnerable plugin and a place the server could write freely. The permissions above narrow the blast radius. It’s not a fortress on its own, but it turns canvas doors into solid wood.

A quiet extra: disable file editing via the dashboard

Even if you trust everyone with wp-admin access, it’s smart to turn off the built-in theme and plugin editors. It removes a whole pathway for accidental changes—or intentional misuse. Pop this into wp-config.php:

define('DISALLOW_FILE_EDIT', true);

Want the canonical guidance on file permissions and general hardening? The official page is a helpful reference when you want to sanity-check yourself: WordPress hardening guide.

Salt Keys: The Little Locks That Keep Sessions Honest

Here’s a secret almost nobody talks about at first: your WordPress salt keys are like the pins in a lock. They help protect sessions and cookies from being guessed or forged. If those keys are old, copied from a public example, or floating around in a repo, you’re basically leaving your spare key under the mat.

How to rotate salt keys without upsetting anyone

Rotating salts is one of those “five-minute chores that pays off for months.” When you update them, any logged-in sessions get kicked out and must sign in again—slightly annoying, but worth it. To do it quickly, use the official generator:

# Get fresh salts from WordPress API and replace them in wp-config.php
curl -s https://api.wordpress.org/secret-key/1.1/salt/

Copy the output and replace the corresponding lines in wp-config.php. Do this any time you suspect creds were exposed, a developer left the team, or after a major incident. If you’re comfortable with WP-CLI, there’s an even breezier option:

wp config shuffle-salts

I’ve had clients ask, “But won’t this break the site?” The site keeps serving pages just fine. Only existing login sessions are invalidated—and sometimes that’s exactly what you want after tightening things up.

Bookmark the generator for when you need it in a hurry: WordPress secret-key service.

XML-RPC: When to Keep It, When to Put It on a Short Leash

XML-RPC is like that side door you rarely use. It’s there for remote publishing, some mobile apps, and certain services that connect to your WordPress from afar. Problem is, bots love hammering it—especially to brute-force logins or trigger resource-heavy actions.

I’ve seen two kinds of teams: those who don’t need XML-RPC at all, and those who depend on it for a handful of workflows (Jetpack, mobile posting, external editors). For the first group, the simplest path is to disable it completely. For the second, the goal is to allow the few things you need and block the rest.

The easy off switch

If you’re on Apache and not using XML-RPC, you can short-circuit requests in .htaccess:

<Files xmlrpc.php>
  Order allow,deny
  Deny from all
</Files>

On Nginx, a location block does the same job:

location = /xmlrpc.php {
    deny all;
}

When you absolutely must keep it, rate limiting and IP allowlists can calm things down. And this is where a friendly combo of Nginx rules plus Fail2ban shines. If you want a deeper dive into the mechanics, I wrote about it in the calm way to stop wp-login.php and XML-RPC brute force. It’s the same idea we’ll use in the next section, but with gentler hands for endpoints you still care about.

One more practical tip: if you’re using an external service and aren’t sure whether it needs XML-RPC, try temporarily disabling it and watch the service logs. If something breaks, you’ve learned where the dependency lives; if not, you’ve removed a noisy door from your house.

UFW + Fail2ban: The Doormen Who Don’t Mess Around

When it comes to server-level defense, I picture two very polite bouncers at the club door. UFW decides who can even come near the building. Fail2ban watches the line, and if someone keeps jabbing at the doorbell, they take a quick walk down the street for a cooldown. Together, they reduce the noise so your app and database don’t have to deal with tantrums.

UFW: Default deny, then invite who matters

UFW is like flipping on a simple “only these ports are allowed” switch. On Ubuntu, here’s my usual starting point:

# Allow SSH, then Web (HTTP/HTTPS)
sudo ufw allow OpenSSH
sudo ufw allow 80
sudo ufw allow 443

# Default rules: block incoming, allow outgoing
sudo ufw default deny incoming
sudo ufw default allow outgoing

# Enable and check
sudo ufw enable
sudo ufw status verbose

That’s enough to keep life quiet on most VPS setups. If you’re curious about deeper patterns—rate limiting, port knocking, IPv6 nuance—I’ve shared a complete firewall cookbook with nftables as an alternative to UFW in this firewall playbook. Prefer the official UFW docs? Keep this in your pocket: Ubuntu UFW documentation.

Fail2ban: Real-time timeout for noisy neighbors

Fail2ban lives off your logs and makes snap decisions: “This IP tried ten passwords in 30 seconds? They can sit out for 15 minutes.” It’s not perfect, but I love how it takes constant nagging off the table. Here’s a lightweight way to tackle both wp-login.php and xmlrpc.php with Nginx access logs.

First, create a filter for WordPress login abuse. Save this as /etc/fail2ban/filter.d/wordpress-login.conf:

[Definition]
failregex = ^<HOST> -.*POST /wp-login.php HTTP/.*
ignoreregex =

Now a filter for XML-RPC brute force (lots of POSTs to xmlrpc.php):

[Definition]
failregex = ^<HOST> -.*POST /xmlrpc.php HTTP/.*
ignoreregex =

Next, wire them into a jail. In /etc/fail2ban/jail.local, add:

[wordpress-login]
enabled  = true
port     = http,https
filter   = wordpress-login
logpath  = /var/log/nginx/access.log
maxretry = 5
findtime = 600
bantime  = 900

[wordpress-xmlrpc]
enabled  = true
port     = http,https
filter   = wordpress-xmlrpc
logpath  = /var/log/nginx/access.log
maxretry = 10
findtime = 600
bantime  = 1800

Reload Fail2ban to apply changes:

sudo systemctl restart fail2ban
sudo fail2ban-client status wordpress-login
sudo fail2ban-client status wordpress-xmlrpc

You’ll start seeing bans for repeat offenders pretty quickly. If your access logs are elsewhere or you’re on Apache, just point logpath to the right file. And if you want to be extra nice to real users, add Nginx rate limiting to “slow down” bots before they trip the ban. I go into rate limits and practical thresholds in this gentle anti-brute-force guide too.

One last note from the field: tune bans to your audience. For internal dashboards with a small team, a shorter findtime and longer bantime make sense. For public sites with varied traffic, be generous with retries but strict with cold repeaters. You’re balancing kindness with boundaries.

Putting It All Together: A Calm, Copy‑Friendly Checklist

This is the flow I use when I’m hardening a WordPress site without drama. It reads like a story because I’ve learned to follow it in this order to avoid surprises.

1) Confirm your footing

Take a fresh backup and verify you can restore it. Make sure you have shell access with keys and a way to view logs comfortably. If you’re building a new VPS, get your SSH basics right first—my walkthrough in this SSH hardening guide is a good warm-up.

2) Fix ownership and permissions

Set ownership so your deploy user owns the files and the web server is the group. Sweep directories to 755, files to 644. Give wp-config.php the 640 treatment. Allow group write only where the server must write—uploads, cache, maybe a custom storage directory you know by heart.

3) Disable file editing in the dashboard

Add define('DISALLOW_FILE_EDIT', true); to wp-config.php. It’s one small line that closes a door you’ll rarely miss.

4) Rotate the salt keys

Use the official salt generator or wp config shuffle-salts. Tell your team logins will reset—make it a quick five-minute window, ideally during a low-traffic moment.

5) Decide the fate of XML-RPC

If you don’t use it, deny access outright in your web server. If you do use it, restrict by IP if possible, rate limit, and prepare a Fail2ban rule for repeat POST attempts. If you want a battle-tested template, borrow from my calm rate-limiting + Fail2ban setup.

6) Enable UFW, then teach Fail2ban to be your bouncer

Open only what you need—SSH, HTTP, HTTPS—and default deny the rest. Then let Fail2ban watch the access logs for wp-login.php and xmlrpc.php and hand out short bans to repeat offenders. If you feel like leveling up, I share a more advanced firewall strategy with nftables in this firewall cookbook.

7) Keep an ear on the room

Watch your logs for a day or two. You’ll spot patterns quickly—bots that love a certain path, a plugin throwing loud warnings, or a wave of 403s because you got a bit too strict. Dial things up or down as you learn. If you want to go further, I’ve laid out a friendly way to centralize and search logs in this guide to Loki + Promtail.

A Few Real‑World Moments That Changed How I Harden Sites

One of my clients ran a busy blog with guest writers scattered across time zones. They depended on a mobile app that leaned on XML-RPC. Disabling it wasn’t an option, but the brute-force noise was driving their CPU through the roof. The fix that finally stuck was a mix of three small tweaks: a strict Nginx rate limit on /xmlrpc.php, a Fail2ban rule that only bans on multiple failed logins (not just any POST), and a narrow allowlist for the app’s known IP ranges. Traffic stayed smooth, and the attack graphs looked like someone shut off a faucet.

Another team inherited a site where every file was writable by everyone. It had “worked” for years—until it didn’t. The switch to sensible ownership and permissions took an hour, but the relief was instant. Suddenly, backups didn’t include malware stubs in every directory, and their SFTP wasn’t a minefield. We also tossed in DISALLOW_FILE_EDIT and rotated salt keys for good measure. A month later, they messaged me saying, “We’ve had fewer scary alerts this month than in any month last year.” It wasn’t magic, just housekeeping.

I share these not to brag, but to remind you: small, boring steps add up. You don’t need a PhD in cryptography to keep a WordPress site tidy and resilient. You just need a rhythm and a checklist.

Wrap‑Up: The Friendly Way to Stay Secure Without Losing Sleep

If you take only one thing from this, let it be this: hardening WordPress is less about paranoia and more about boundaries. Reasonable file permissions so your server isn’t a free-for-all. Fresh salt keys so sessions are trustworthy and disposable. XML-RPC on a need-to-use basis, not by default. And a calm pair of bouncers—UFW to run the door, Fail2ban to handle the occasional loudmouth.

Make these steps part of your routine. Put a quarterly reminder to rotate salts. Review permissions after big deployments or team changes. Revisit your XML-RPC choice when workflows evolve. And keep your firewall rules simple enough that Future You remembers why they exist. If you want to go deeper into related topics, I’ve written about Nginx microcaching to make PHP feel snappy and how smarter server patterns keep your site resilient even on busy days.

Hope this was helpful! If you’ve got stories from the trenches or a question I didn’t cover, drop me a note. I love hearing how others keep their WordPress sites safe without the drama.

Frequently Asked Questions

Great question! Aim for directories at 755 and files at 644. Tighten wp-config.php to 640. For places the server must write—like wp-content/uploads—use 775 for directories and 664 for files. Keep ownership clean: your deploy user owns the files, the web server is the group. That balance lets WordPress upload media while keeping write access limited everywhere else.

If you don’t use mobile apps, Jetpack, or external editors, disabling XML-RPC is the easiest win. If you do need it, don’t leave it wide open—restrict by IP when possible, add Nginx/Apache rules to rate limit or deny suspicious patterns, and use Fail2ban to ban repeat offenders. That way you keep what you need and ditch the bot noise.

It’s quick and safe. Grab fresh keys from the WordPress secret-key API and replace the existing salts in wp-config.php, or run wp config shuffle-salts if you use WP-CLI. Users will be logged out and need to sign in again, but the site keeps serving pages normally. I like to do it during a low-traffic window and tell the team in advance.