Hardening the Stack: A Comprehensive Guide to WordPress Security

By Collin

A default WordPress installation is a target. To move from “vulnerable” to “hardened,” we must secure the entire stack: the Operating System, the Web Server, the Database, and the Application itself. This guide follows the official WordPress hardening standards to build a fortress, not just a website.

Environment: DigitalOcean Droplet running Ubuntu 24.04, Nginx web server, MariaDB database, managed through Cloudflare (DNS + proxy).


1. The Foundation: Server-Level Hardening (OS)

Before touching WordPress, you must secure the host. If the server is compromised, the application doesn’t matter.

SSH Key-Based Authentication

Standard password logins are susceptible to brute-force attacks. We replace “what you know” (password) with “what you have” (private key).

Action: Disable password-based authentication.

Implementation:

sudo nano /etc/ssh/sshd_config

Find and modify these lines:

PasswordAuthentication no
PubkeyAuthentication yes
PermitRootLogin no

Restart SSH:

sudo systemctl restart ssh

Verification: Try logging in from a different machine without a key – it should fail.

Firewall Configuration (UFW)

Ubuntu’s Uncomplicated Firewall (UFW) provides a simple interface for iptables. The principle: deny everything except what you explicitly need.

Action: Configure UFW to allow only HTTP, HTTPS, and SSH.

Implementation:

# Check status first
sudo ufw status

# Default policies: deny incoming, allow outgoing
sudo ufw default deny incoming
sudo ufw default allow outgoing

# Allow SSH (do this FIRST to avoid locking yourself out)
sudo ufw allow 22/tcp

# Allow HTTP and HTTPS
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp

# Enable the firewall
sudo ufw enable

# Verify rules
sudo ufw status verbose

Expected output:

To                         Action      From
--                         ------      ----
22/tcp                     ALLOW       Anywhere
80/tcp                     ALLOW       Anywhere
443/tcp                    ALLOW       Anywhere

Critical: If you’re using a non-standard SSH port, adjust accordingly. If you use Cloudflare’s proxy (orange cloud), you can optionally restrict port 80/443 to Cloudflare’s IP ranges for additional protection.

Automatic Security Updates

The WordPress Codex emphasizes keeping server software current. We use unattended-upgrades to ensure the OS patches itself.

Action: Enable automated security patching.

Implementation:

sudo apt install unattended-upgrades
sudo dpkg-reconfigure -plow unattended-upgrades

Select “Yes” when prompted. This configures automatic installation of security updates only.

Verification:

sudo systemctl status unattended-upgrades

Configuration file location: /etc/apt/apt.conf.d/50unattended-upgrades

You can optionally configure email notifications for update failures. This is crucial for a production site.


2. The Perimeter: Nginx & Network Security

The web server is the gatekeeper. By configuring Nginx properly, we stop attacks before they reach the WordPress PHP engine.

Security Headers

We use HTTP headers to instruct the visitor’s browser to block common exploitation techniques.

Action: Add protection against Clickjacking, MIME-sniffing, and referrer leakage.

Location: Add to your Nginx server block (typically in /etc/nginx/sites-available/[your_domain])

Configuration:

server {
    listen 443 ssl http2;
    server_name [your_domain].com www.[your_domain].com;
    
    # Security Headers
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;
    add_header X-XSS-Protection "1; mode=block" always;
    
    # Additional recommended headers
    add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
    
    # Rest of your configuration...
}

Header explanations:

  • X-Frame-Options: Prevents your site from being embedded in iframes (clickjacking protection)
  • X-Content-Type-Options: Prevents browsers from MIME-sniffing (changing file types)
  • Referrer-Policy: Controls how much referrer information is sent
  • X-XSS-Protection: Enables browser’s built-in XSS filter (legacy, but doesn’t hurt)
  • Permissions-Policy: Disables unnecessary browser features

Test after applying:

sudo nginx -t
sudo systemctl reload nginx

Disable Directory Indexing

Prevent attackers from browsing your directory structure.

Action: Ensure autoindex is disabled globally.

Configuration: In your main nginx.conf (/etc/nginx/nginx.conf) or site config:

autoindex off;

Test: Navigate to https://[your_domain].com/wp-content/uploads/ – you should get a 403 Forbidden, not a file listing.

Blocking Access to Sensitive Files

Nginx should never serve files like .htaccess, wp-config.php, or other sensitive items.

Configuration: Add to your server block:

# Block access to hidden files
location ~ /\. {
    deny all;
}

# Block access to wp-config.php
location = /wp-config.php {
    deny all;
}

# Block access to readme/license files
location ~* ^/readme\.(txt|html)$ {
    deny all;
}

location ~* ^/license\.txt$ {
    deny all;
}

Rate Limiting (Bonus: Advanced)

Protect against brute-force login attempts at the web server level.

Configuration: In your http block in nginx.conf:

# Define rate limit zone (outside server block)
limit_req_zone $binary_remote_addr zone=wp_login:10m rate=3r/m;

# In your server block, apply to wp-login.php
location = /wp-login.php {
    limit_req zone=wp_login burst=5;
    include fastcgi_params;
    fastcgi_pass unix:/var/run/php/php8.3-fpm.sock;
}

This limits login attempts to 3 per minute per IP, with a burst allowance of 5.

Note: Adjust the PHP-FPM socket path to match your PHP version (e.g., php8.1-fpm.sock, php8.2-fpm.sock, php8.3-fpm.sock).


3. The Core: Application-Level Hardening

Now we move into wp-config.php and the WordPress filesystem, following the principle of Least Privilege.

Custom Database Prefix

Automated SQL Injection (SQLi) attacks assume your tables start with wp_. Changing this breaks the vast majority of automated scripts.

Technical Pitfall: Failing to update internal meta references will lock you out of the admin dashboard. Always backup before this operation. Complete all changes before applying and updating.

Action: Change the prefix and rename all tables.

Step 1: Update wp-config.php:

$table_prefix = 'AB_';

Step 2: Rename all database tables:

-- Connect to MySQL
mysql -u [wordpress_user] -p [wordpress_db]

-- Rename core tables
RENAME TABLE wp_users TO AB_users;
RENAME TABLE wp_posts TO AB_posts;
RENAME TABLE wp_postmeta TO AB_postmeta;
RENAME TABLE wp_usermeta TO AB_usermeta;
RENAME TABLE wp_comments TO AB_comments;
RENAME TABLE wp_commentmeta TO AB_commentmeta;
RENAME TABLE wp_terms TO AB_terms;
RENAME TABLE wp_term_taxonomy TO AB_term_taxonomy;
RENAME TABLE wp_term_relationships TO AB_term_relationships;
RENAME TABLE wp_termmeta TO AB_termmeta;
RENAME TABLE wp_options TO AB_options;
RENAME TABLE wp_links TO AB_links;

Step 3 (Critical): Update internal WordPress references:

-- Update options table
UPDATE AB_options 
SET option_name = 'AB_user_roles' 
WHERE option_name = 'wp_user_roles';

-- Update user meta keys
UPDATE AB_usermeta 
SET meta_key = REPLACE(meta_key, 'wp_', 'AB_');

Step 4: If using plugins (e.g., Wordfence, LiteSpeed Cache), you must also rename their tables:

-- Example for Wordfence
RENAME TABLE wp_wfconfig TO AB_wfconfig;
RENAME TABLE wp_wfblocks7 TO AB_wfblocks7;
-- Check SHOW TABLES; for all plugin tables and rename accordingly

High-Entropy Security Salts

Salts encrypt the information stored in user cookies. Default or weak salts are a security risk.

Action: Regenerate secret keys using WordPress’s official API.

Implementation:

# Visit this URL to generate fresh salts (in a browser or via curl)
# https://api.wordpress.org/secret-key/1.1/salt/

# Edit wp-config.php
sudo nano /var/www/[your_wordpress_directory]/wp-config.php

Replace this entire block:

define('AUTH_KEY',         'put your unique phrase here');
define('SECURE_AUTH_KEY',  'put your unique phrase here');
define('LOGGED_IN_KEY',    'put your unique phrase here');
define('NONCE_KEY',        'put your unique phrase here');
define('AUTH_SALT',        'put your unique phrase here');
define('SECURE_AUTH_SALT', 'put your unique phrase here');
define('LOGGED_IN_SALT',   'put your unique phrase here');
define('NONCE_SALT',       'put your unique phrase here');

With fresh values from the API. Each key should be a 64-character random string.

Impact: Regenerating salts will log out all users. Schedule this during maintenance.

Disable File Editing from Dashboard

The WordPress dashboard allows administrators to edit PHP files (plugins, themes) by default. This is the first tool an attacker uses if they compromise an admin account.

Action: Disable the built-in code editor.

Implementation: Add to wp-config.php (above the /* That's all, stop editing! */ line):

// Disable file editing
define('DISALLOW_FILE_EDIT', true);

Verification: Log into WordPress admin → Appearance. The “Theme File Editor” option should be gone.

Trade-off: You’ll need SFTP/SSH access to edit theme files. This is actually proper practice anyway.

File Permissions

Incorrect file permissions are one of the most common vulnerabilities. The rule: only the files that absolutely need to be writable should be writable.

Action: Set restrictive permissions on WordPress files.

Implementation:

# Navigate to WordPress root
cd /var/www/[your_wordpress_directory]

# Set ownership (replace 'www-data' with your web server user if different)
sudo chown -R www-data:www-data .

# Set directory permissions to 755 (owner: rwx, group/others: rx)
find . -type d -exec chmod 755 {} \;

# Set file permissions to 644 (owner: rw, group/others: r)
find . -type f -exec chmod 644 {} \;

# Special case: wp-config.php should be 440 or 400 (read-only, not world-readable)
chmod 440 wp-config.php

# Verify critical files
ls -la wp-config.php
# Should show: -r--r----- (440)

Permission breakdown:

  • 755 for directories: Owner can read/write/execute, others can read/execute
  • 644 for files: Owner can read/write, others can only read
  • 440 for wp-config.php: Only owner and group can read, no one can write

Writeable directories (WordPress needs these for uploads, cache, etc.):

chmod 755 wp-content/uploads
chmod 755 wp-content/cache  # if you use caching

If using a plugin that requires write access to specific directories, only grant it there. Never make the entire wp-content writable.

Additional wp-config.php Hardening

Beyond salts and file editing, there are several other hardening constants.

Add these to wp-config.php:

// --- Hardening Constants ---

// Disable plugin/theme installation from dashboard (set to true after initial setup)
define('DISALLOW_FILE_MODS', false);

// Force SSL for admin area (if using HTTPS, which you should be)
define('FORCE_SSL_ADMIN', true);

// Limit post revisions to save database space
define('WP_POST_REVISIONS', 3);

// Set auto-save interval (default is 60 seconds, increase to reduce DB writes)
define('AUTOSAVE_INTERVAL', 300); // 5 minutes

For Nginx users: To disable PHP execution in uploads, add to your Nginx config:

location ~* /wp-content/uploads/.*\.php$ {
    deny all;
}

4. The Data: Database Hardening

Database integrity is paramount. We must ensure proper privilege separation and backup procedures.

Restrict Database User Privileges

For normal WordPress operations (posting, uploading media, installing plugins), the MySQL user only needs: SELECT, INSERT, UPDATE, DELETE.

Dangerous privileges like DROP, ALTER, GRANT should be revoked.

Action: Create a separate admin user for schema changes, use a restricted user for WordPress.

Implementation:

-- Connect to MySQL as root
sudo mysql -u root

-- Check current privileges
SHOW GRANTS FOR '[wordpress_user]'@'localhost';

-- If the user has excessive privileges, revoke them
REVOKE ALL PRIVILEGES ON [wordpress_db].* FROM '[wordpress_user]'@'localhost';

-- Grant only necessary privileges
GRANT SELECT, INSERT, UPDATE, DELETE ON [wordpress_db].* TO '[wordpress_user]'@'localhost';

-- Apply changes
FLUSH PRIVILEGES;

-- Exit
EXIT;

Critical caveat: Major WordPress updates and some plugins require ALTER and CREATE to modify the database schema. Before running updates:

  1. Temporarily grant privileges:
GRANT ALL PRIVILEGES ON [wordpress_db].* TO '[wordpress_user]'@'localhost';
FLUSH PRIVILEGES;
  1. Run the update
  2. Revoke again:
REVOKE ALTER, CREATE, DROP, INDEX ON [wordpress_db].* FROM '[wordpress_user]'@'localhost';
FLUSH PRIVILEGES;

Better approach for advanced users: Use two database users – one restricted for runtime, one with full access for updates. Switch between them in wp-config.php as needed.

Database Backups

No hardening guide is complete without backups. The question isn’t if you’ll need them, it’s when.

Action: Set up automated database backups with off-site storage.

Implementation (using mysqldump):

# Create backup directory
sudo mkdir -p /var/backups/wordpress
sudo chown $(whoami):$(whoami) /var/backups/wordpress

# Create backup script
sudo nano /usr/local/bin/backup-wordpress-db.sh

Backup script:

#!/bin/bash
BACKUP_DIR="/var/backups/wordpress"
DB_NAME="[wordpress_db]"
DB_USER="[wordpress_user]"
DB_PASS="[your_db_password]"  # Better: read from secure file
DATE=$(date +%Y%m%d_%H%M%S)
FILENAME="wp_backup_$DATE.sql.gz"

# Dump and compress
mysqldump -u $DB_USER -p$DB_PASS $DB_NAME | gzip > $BACKUP_DIR/$FILENAME

# Keep only last 7 days
find $BACKUP_DIR -name "wp_backup_*.sql.gz" -mtime +7 -delete

# Optional: Upload to remote storage (S3, Backblaze, etc.)
# aws s3 cp $BACKUP_DIR/$FILENAME s3://your-bucket/backups/

Make executable and schedule:

sudo chmod +x /usr/local/bin/backup-wordpress-db.sh

# Add to crontab (daily at 2 AM)
crontab -e

Add line:

0 2 * * * /usr/local/bin/backup-wordpress-db.sh

Security note: Never store database credentials in plain text. Use a secrets manager or at minimum a restricted-permission file.

Database Connection Security

Ensure WordPress connects to MySQL over localhost (Unix socket) rather than TCP, and uses a strong password.

In wp-config.php:

define('DB_HOST', 'localhost');  // or '127.0.0.1' - both use socket by default

Generate strong database password:

openssl rand -base64 32

Update the password in both wp-config.php and MySQL:

ALTER USER '[wordpress_user]'@'localhost' IDENTIFIED BY 'new_strong_password';
FLUSH PRIVILEGES;

5. Maintenance: Monitoring & Auditing

A hardened site requires continuous vigilance. Security is not a one-time configuration.

Log Monitoring

When issues arise, logs are your forensic evidence.

Action: Configure centralized logging and regular review.

Log locations:

  • Nginx access log: /var/log/nginx/access.log
  • Nginx error log: /var/log/nginx/error.log
  • PHP-FPM error log: /var/log/php8.3-fpm.log (adjust version number)

Real-time monitoring:

# Monitor Nginx errors
sudo tail -f /var/log/nginx/error.log

# Watch rate limiter in action
sudo tail -f /var/log/nginx/access.log | grep "503"

# Monitor all Nginx access (useful during attacks)
sudo tail -f /var/log/nginx/access.log

# Filter for failed login attempts
sudo grep "wp-login.php" /var/log/nginx/access.log | grep "POST"

# PHP health check (adjust to your PHP version)
sudo tail -f /var/log/php8.3-fpm.log

Setting log rotation (prevents logs from filling disk):

# Nginx logs are rotated by default via logrotate
cat /etc/logrotate.d/nginx

Advanced: Install fail2ban for Automated IP Blocking

sudo apt install fail2ban

# Create WordPress jail
sudo nano /etc/fail2ban/jail.local

Fail2ban WordPress jail configuration:

[wordpress-auth]
enabled = true
filter = wordpress-auth
logpath = /var/log/nginx/access.log
maxretry = 3
bantime = 3600
findtime = 600

Create the filter:

sudo nano /etc/fail2ban/filter.d/wordpress-auth.conf
[Definition]
failregex = ^<HOST> .* "POST /wp-login.php
ignoreregex =

Restart fail2ban:

sudo systemctl restart fail2ban
sudo fail2ban-client status wordpress-auth

Cloudflare + fail2ban: Restoring Real IPs

The Problem: If you’re using Cloudflare in proxy mode (orange cloud), Nginx sees all requests coming from Cloudflare’s IP addresses, not the actual visitor’s IP. This breaks fail2ban because:

  1. Nginx logs show Cloudflare IPs (e.g., 104.16.x.x) instead of attacker IPs
  2. fail2ban tries to ban Cloudflare’s servers (bad)
  3. Attackers never get blocked (worse)

The Solution: Configure Nginx to trust Cloudflare’s proxy headers and extract the real visitor IP using the ngx_http_realip_module.

Action: Configure Nginx to restore real IPs from Cloudflare headers.

Implementation:

First, verify the realip module is available:

nginx -V 2>&1 | grep -o with-http_realip_module
# Should output: with-http_realip_module

Create a Cloudflare IP configuration file:

sudo nano /etc/nginx/cloudflare-realip.conf

Add Cloudflare’s current IP ranges:

# Cloudflare IPv4 ranges
set_real_ip_from 173.245.48.0/20;
set_real_ip_from 103.21.244.0/22;
set_real_ip_from 103.22.200.0/22;
set_real_ip_from 103.31.4.0/22;
set_real_ip_from 141.101.64.0/18;
set_real_ip_from 108.162.192.0/18;
set_real_ip_from 190.93.240.0/20;
set_real_ip_from 188.114.96.0/20;
set_real_ip_from 197.234.240.0/22;
set_real_ip_from 198.41.128.0/17;
set_real_ip_from 162.158.0.0/15;
set_real_ip_from 104.16.0.0/13;
set_real_ip_from 104.24.0.0/14;
set_real_ip_from 172.64.0.0/13;
set_real_ip_from 131.0.72.0/22;

# Cloudflare IPv6 ranges
set_real_ip_from 2400:cb00::/32;
set_real_ip_from 2606:4700::/32;
set_real_ip_from 2803:f800::/32;
set_real_ip_from 2405:b500::/32;
set_real_ip_from 2405:8100::/32;
set_real_ip_from 2a06:98c0::/29;
set_real_ip_from 2c0f:f248::/32;

# Use the CF-Connecting-IP header (Cloudflare's real IP header)
real_ip_header CF-Connecting-IP;

# Use X-Forwarded-For as fallback
# real_ip_header X-Forwarded-For;

# Don't trust any other proxies
real_ip_recursive off;

Note: Cloudflare updates their IP ranges occasionally. Get the current list from: https://www.cloudflare.com/ips/

Include in your main Nginx configuration:

sudo nano /etc/nginx/nginx.conf

Add inside the http block (before your server blocks):

http {
    # ... other settings ...
    
    # Cloudflare real IP restoration
    include /etc/nginx/cloudflare-realip.conf;
    
    # ... rest of config ...
}

Test and reload Nginx:

sudo nginx -t
sudo systemctl reload nginx

Verification:

Check that Nginx is now logging real IPs:

# Watch access log
sudo tail -f /var/log/nginx/access.log
# Visit your site from your phone/computer
# You should see YOUR actual IP, not a Cloudflare IP (104.16.x.x)

Test fail2ban sees real IPs:

# Intentionally fail a login 3+ times from your test machine
# Then check fail2ban log
sudo tail -f /var/log/fail2ban.log

# Check if YOUR IP got banned (not Cloudflare's)
sudo fail2ban-client status wordpress-auth
# Should show your actual IP in the banned list

Unban yourself if needed:

sudo fail2ban-client set wordpress-auth unbanip YOUR_IP_ADDRESS

Security note: Only trust Cloudflare IPs if you’re actually behind Cloudflare. If an attacker bypasses Cloudflare and connects directly to your origin server, they could spoof the CF-Connecting-IP header. Protect against this by:

  1. Firewall your origin server to only accept connections from Cloudflare IPs:
# UFW example - allow only Cloudflare
sudo ufw default deny incoming
sudo ufw allow from 173.245.48.0/20 to any port 80
sudo ufw allow from 173.245.48.0/20 to any port 443
# ... repeat for all Cloudflare ranges ...
# Or use a script: https://github.com/Paul-Reed/cloudflare-ufw
  1. Use Authenticated Origin Pulls (Cloudflare’s mTLS) to cryptographically verify requests are from Cloudflare

Alternative approach: If you don’t want to maintain Cloudflare IP lists, use their API to automatically update:

# Create update script
sudo nano /usr/local/bin/update-cloudflare-ips.sh
#!/bin/bash
CF_IPSV4_URL="https://www.cloudflare.com/ips-v4"
CF_IPSV6_URL="https://www.cloudflare.com/ips-v6"
CONF_FILE="/etc/nginx/cloudflare-realip.conf"

# Fetch current IPs
echo "# Auto-generated Cloudflare IPs - $(date)" > $CONF_FILE
echo "" >> $CONF_FILE
echo "# IPv4" >> $CONF_FILE
for ip in $(curl -s $CF_IPSV4_URL); do
    echo "set_real_ip_from $ip;" >> $CONF_FILE
done
echo "" >> $CONF_FILE
echo "# IPv6" >> $CONF_FILE
for ip in $(curl -s $CF_IPSV6_URL); do
    echo "set_real_ip_from $ip;" >> $CONF_FILE
done
echo "" >> $CONF_FILE
echo "real_ip_header CF-Connecting-IP;" >> $CONF_FILE
echo "real_ip_recursive off;" >> $CONF_FILE

# Test and reload Nginx
nginx -t && systemctl reload nginx

Make executable and schedule monthly:

sudo chmod +x /usr/local/bin/update-cloudflare-ips.sh
sudo crontab -e
# Add: 0 3 1 * * /usr/local/bin/update-cloudflare-ips.sh

WordPress Security Plugins

Defense in depth means layering multiple security measures. Here are recommended plugins:

1. Wordfence Security (Free)

  • Web Application Firewall (WAF)
  • Malware scanner
  • Login security & 2FA
  • Real-time traffic monitoring

Installation:

# Via WP-CLI (if installed)
wp plugin install wordfence --activate --allow-root

# Or via dashboard: Plugins → Add New → Search "Wordfence"

Configuration priorities:

  • Enable 2FA for all admin users
  • Configure email alerts for critical events
  • Run scheduled scans weekly
  • Enable “Extended Protection” (free vs premium trade-off)

2. Alternative: Solid Security (formerly iThemes Security)

  • Similar feature set to Wordfence
  • Lighter resource usage
  • Better for shared hosting

Pick one WAF plugin, not both. Running multiple security plugins can cause conflicts.

File Integrity Monitoring

Detect unauthorized changes to core WordPress files.

Action: Use WordPress’s built-in file verification.

Via WP-CLI: (run from within the web root /var/www/[your_domain])

# Verify core files (detects modifications)
wp core verify-checksums --allow-root

# Example output shows modified files:
# Warning: File doesn't verify against checksum: wp-admin/admin.php

For continuous monitoring, install a plugin like WP Security Audit Log or use OSSEC (advanced, server-level).

Keep WordPress Updated

This is basic but critical. Most compromises exploit known, patched vulnerabilities.

Enable automatic updates for minor versions (in wp-config.php):

// Auto-update minor core releases (e.g., 6.4.1 to 6.4.2)
define('WP_AUTO_UPDATE_CORE', 'minor');

// To enable major updates (use with caution):
// define('WP_AUTO_UPDATE_CORE', true);

Plugin updates: Review release notes before updating, especially for major versions. Test on staging first if possible.

Via WP-CLI (safer for automation):

# Update WordPress core
wp core update --allow-root

# Update all plugins
wp plugin update --all --allow-root

# Update all themes
wp theme update --all --allow-root

The 502 Bad Gateway Lesson

Real-world incident: After implementing the database prefix change, the site returned a 502 error.

Root cause: Wordfence couldn’t find its configuration tables because they still had the wp_ prefix while WordPress was looking for AB_ tables.

Investigation:

# Check Nginx error log
sudo tail -f /var/log/nginx/error.log
# Output: "upstream sent invalid header while reading response header from upstream"

# Check PHP-FPM log
sudo tail -f /var/log/php8.3-fpm.log
# Output: "WordPress database error Table 'wordpress_db.AB_wfconfig' doesn't exist"

Solution: Query all database tables and verify prefix consistency:

SHOW TABLES;
-- Found leftover wp_wfconfig, wp_wfblocks7, etc.

RENAME TABLE wp_wfconfig TO AB_wfconfig;
RENAME TABLE wp_wfblocks7 TO AB_wfblocks7;
-- (total: 39 tables needed renaming)

Lesson: When changing database prefixes, audit ALL tables, not just WordPress core tables. Plugins create their own schema.


6. Defense in Depth: The Cloudflare Layer

If you’re using Cloudflare in proxy mode (orange cloud), you get an additional security layer in front of your server.

Cloudflare Security Settings

Navigate to: Security → Settings in Cloudflare dashboard

Recommended configuration:

  • Security Level: High (challenges suspicious visitors)
  • Challenge Passage: 30 minutes
  • Browser Integrity Check: On
  • Privacy Pass Support: On

Cloudflare Firewall Rules

Free plan users get 5 firewall rules. Use them wisely.

Example rule: Block known bad bots

(cf.bot_management.score lt 30)

Action: Block

Example rule: Rate limit wp-login.php

(http.request.uri.path contains "/wp-login.php")

Action: Challenge
Rate: 3 requests per minute

Cloudflare Page Rules

Force HTTPS redirect (free with Cloudflare):

URL: http://*[your_domain].com/*
Setting: Always Use HTTPS

SSL/TLS Configuration

Verify SSL/TLS Strict mode is enabled:

  • SSL/TLS → Overview: “Full (strict)” mode selected
  • Edge Certificates → Always Use HTTPS: On
  • HSTS: Enabled with minimum 6 months max-age

On your server, ensure valid SSL certificate:

# If using Let's Encrypt
sudo certbot renew --dry-run

# Check certificate validity
openssl x509 -in /etc/letsencrypt/live/[your_domain].com/cert.pem -noout -dates

Final Security Checklist

A comprehensive verification list before considering your WordPress installation hardened:

Server Level

  • [ ] SSH key-only authentication (passwords disabled)
  • [ ] UFW firewall configured (ports 22, 80, 443 only)
  • [ ] Automatic security updates enabled
  • [ ] fail2ban installed and configured (optional but recommended)
  • [ ] Non-root user for server management

Web Server (Nginx)

  • [ ] Security headers active (X-Frame-Options, CSP, etc.)
  • [ ] Directory indexing disabled
  • [ ] Sensitive files blocked (.htaccess, wp-config.php, readme.txt)
  • [ ] PHP execution disabled in uploads directory
  • [ ] Rate limiting on wp-login.php (optional)
  • [ ] SSL/TLS configured (Cloudflare handles this)

WordPress Application

  • [ ] Custom database prefix (AB_)
  • [ ] Security salts regenerated
  • [ ] File editing disabled (DISALLOW_FILE_EDIT)
  • [ ] File permissions set correctly (755/644)
  • [ ] wp-config.php readable only by owner (440)
  • [ ] Default content deleted (Hello World post, Sample Page)
  • [ ] Admin username is not “admin”
  • [ ] All user accounts use strong passwords
  • [ ] Two-factor authentication enabled for admins

Database

  • [ ] Database user privileges restricted (SELECT, INSERT, UPDATE, DELETE only)
  • [ ] Strong database password (32+ characters)
  • [ ] Connection over localhost/socket
  • [ ] Automated backups configured (daily minimum)
  • [ ] Backup restoration tested (critical – untested backups are useless)

Ongoing Maintenance

  • [ ] WordPress core auto-updates enabled (minor versions)
  • [ ] Plugin updates reviewed weekly
  • [ ] Security plugin installed (Wordfence or Solid Security)
  • [ ] Log monitoring configured
  • [ ] File integrity checks scheduled
  • [ ] Backup retention policy (7-30 days on-server, 90+ days off-site)

Cloudflare (if using)

  • [ ] Security level set to High
  • [ ] Firewall rules configured
  • [ ] SSL/TLS mode: Full (strict)
  • [ ] HSTS enabled
  • [ ] Page rules for HTTPS redirect

Conclusion: Continuous Hardening

Security is not a destination; it’s a continuous process. This guide covers the foundational hardening measures for a WordPress blog, but the threat landscape evolves constantly.

The three pillars of WordPress security:

  1. Prevention: Implement these hardening measures
  2. Detection: Monitor logs, use security plugins, review traffic
  3. Response: Have a recovery plan, maintain tested backups

Next steps:

  • Implement a staging environment for testing updates
  • Consider a Web Application Firewall (beyond Cloudflare’s basic protection)
  • Audit third-party plugins quarterly (delete unused ones)
  • Review access logs monthly for suspicious patterns
  • Subscribe to WordPress security mailing lists

Resources:

By following this guide, you’ve transformed a default WordPress installation into a hardened, defense-in-depth security architecture. Each layer—from the OS firewall to Cloudflare’s WAF—adds redundancy. If one layer fails, others remain.

The philosophy: Make your blog a harder target than the next one. Attackers seek easy wins; don’t be one.


Last updated: February 2026
WordPress Version: 6.4+
Server: Ubuntu 24.04 LTS / Nginx

Leave a Reply

Your email address will not be published. Required fields are marked *