When standard hardening fails
You've already locked down wp-config.php, disabled file editing, and installed a security plugin. Yet alerts keep firing, login attempts slip through rate limits, or mysterious permission denials block legitimate admin actions. The edge cases below come from production environments where textbook advice fell short.
1. File-permission race conditions under PHP-FPM
WordPress creates files via the web server user, typically www-data or nginx. When you upload a plugin through the dashboard, PHP-FPM writes it as www-data:www-data with 0644 or 0755. Later, if you run wp plugin update --all as your shell user, WP-CLI writes new files as youruser:youruser. Now half the plugin belongs to one owner, half to another.
The symptom: update failures with "Could not create directory" even though wp-content/plugins is 0755. Check actual file ownership:
ls -lah wp-content/plugins/some-plugin/ | head -20
If you see mixed ownership, a cron job or manual chown -R www-data:www-data will fix the immediate problem, but it recurs every WP-CLI run. The real fix is to add your shell user to the www-data group and set the setgid bit:
usermod -aG www-data youruser
chown -R www-data:www-data wp-content/{plugins,themes,uploads}
find wp-content/{plugins,themes,uploads} -type d -exec chmod 2775 {} \;
find wp-content/{plugins,themes,uploads} -type f -exec chmod 0664 {} \;
The 2 in 2775 is the setgid bit. New files inherit the directory's group. All writes—whether from PHP-FPM or WP-CLI—will now be *:www-data, and both processes can modify them. If you also run background jobs as a separate user (for example, a deploy script), add that user to www-data too.
2. Object-cache poisoning with stale nonces
Redis or Memcached drastically improves WordPress performance, but nonces cached in the object store outlive their server-side validity window. A user logs in, gets a nonce, you then rotate salts in wp-config.php or install a security plugin that flushes sessions. The old nonce is still in Redis with a 12-hour TTL. AJAX requests succeed because wp_verify_nonce() checks the cached value first, bypassing the new salt.
I've seen this allow post-logout form submissions. The user's session cookie is invalid, but the nonce validates. WordPress allows the action, thinking the request is legitimate.
Flush the object cache whenever you change salts:
wp cache flush
If you use Redis directly:
redis-cli FLUSHDB
Better yet, prefix cache keys with a version number in your object-cache configuration. When you rotate salts, increment the version. Old keys become orphaned and expire naturally. In wp-content/object-cache.php or your Redis plugin settings, look for a key_salt or global_prefix option and append a version:
$redis_server['key_salt'] = 'mysite_v2_';
Increment v2 to v3 on the next rotation. No manual flush needed.
3. Brute-force filters bypassed by X-Forwarded-For spoofing
Most login-protection plugins throttle by IP. They read $_SERVER['REMOTE_ADDR'], count failed attempts, and block after five tries. If your site sits behind Cloudflare, a load balancer, or Nginx proxy, REMOTE_ADDR is the proxy's internal IP—often 127.0.0.1 or a RFC1918 address. The plugin trusts the X-Forwarded-For header instead.
Attackers rotate the X-Forwarded-For value on every request. Each attempt appears to come from a different IP. The plugin never accumulates five failures from one source.
Check how your plugin reads the IP. If it uses a "trust proxy headers" checkbox, attackers can forge the header unless your server strips client-supplied proxy headers before adding its own. In Nginx:
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
That overwrites any X-Forwarded-For the client sent. If you have multiple proxy layers (Cloudflare → your Nginx → Apache), only the last hop before PHP should set the header. Cloudflare already sets CF-Connecting-IP; configure your plugin to prefer that header and ignore X-Forwarded-For entirely when behind Cloudflare.
Another approach: switch to token-bucket or CAPTCHA-based throttling after three failures, regardless of IP. This stops credential stuffing even if IP tracking is unreliable.
4. Login loops from SameSite cookie mismatches
You log in, the dashboard loads for a split second, then you're back at wp-login.php. Check your browser's developer tools under Application → Cookies. The wordpress_logged_in_* cookie exists, but the SameSite attribute says Lax while your site's domain in the address bar is www.example.com and the cookie's domain is .example.com.
WordPress sets SameSite=Lax by default since 5.3. That's fine for most setups. It breaks when:
- You redirect
example.comtowww.example.combut the cookie was set on the non-www domain. - Your load balancer terminates SSL and the backend sees HTTP, so WordPress sets
Secure=false, but the browser is on HTTPS and rejects insecure cookies on a secure page. - A plugin sets
SameSite=Nonewithout also settingSecure=true, which violates the spec and is ignored by modern browsers.
Force the correct domain and scheme in wp-config.php:
define('COOKIE_DOMAIN', '.example.com');
define('FORCE_SSL_ADMIN', true);
if (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https') {
$_SERVER['HTTPS'] = 'on';
}
That last block tells WordPress the request is HTTPS even when the backend connection is HTTP. Cookies will be set with Secure=true. Make sure your proxy actually sends X-Forwarded-Proto.
5. .htaccess rules wiped by plugin updates
You add custom rewrite rules or security headers to .htaccess outside the # BEGIN WordPress block. A plugin update regenerates the file and your rules vanish. This happens because some plugins call flush_rewrite_rules(true), which writes a fresh .htaccess using only WordPress core and active plugin rules.
Put custom rules in a separate file and Include it:
# .htaccess
# BEGIN WordPress
RewriteEngine On
# ... WordPress rules ...
# END WordPress
Include /home/youruser/public_html/.htaccess-custom
Then in .htaccess-custom:
Header always set X-Content-Type-Options "nosniff"
Header always set X-Frame-Options "SAMEORIGIN"
Header always set Referrer-Policy "strict-origin-when-cross-origin"
The Include directive is processed after the WordPress block, and plugin updates won't touch .htaccess-custom. On Nginx, put custom rules in a separate snippet file and include it in the server block:
include /etc/nginx/custom/example.com-security.conf;
So what if your security plugin conflicts with the host's ModSecurity rules?
Shared hosts often enable ModSecurity with the OWASP Core Rule Set. Rule 941100 blocks requests with SQL keywords in POST bodies. Your security plugin's settings page submits a form with UPDATE wp_options SET ... in a textarea, triggering a 403. The host's logs show a ModSecurity block, but WordPress thinks the save succeeded and shows a success notice.
Request a rule exception from your host, but that takes time. Immediate workaround: base64-encode the payload in JavaScript before submission, then decode in PHP. In your plugin or theme's JavaScript:
document.querySelector('form').addEventListener('submit', function(e) {
let textarea = document.querySelector('textarea[name="custom_sql"]');
textarea.value = btoa(textarea.value);
});
In your plugin's save handler:
if (isset($_POST['custom_sql'])) {
$decoded = base64_decode($_POST['custom_sql']);
update_option('my_plugin_sql', $decoded);
}
ModSecurity sees base64, which looks harmless. Your plugin decodes it server-side. This trick works for any field that trips WAF rules—JSON, XML, code snippets.
6. Two-factor auth locked out by server time drift
TOTP codes depend on synchronized clocks. If your server's time drifts more than 30 seconds from real time, valid codes fail. Check with:
date && curl -sI https://google.com | grep -i date
If the offset exceeds 30 seconds, install chrony or ntpd:
apt install chrony
systemctl enable chrony
systemctl start chrony
chronyc makestep
The makestep command immediately corrects large offsets. For VPS environments where the hypervisor's clock is authoritative, you may need to sync from the hypervisor's time source instead of public NTP pools. Check your VPS provider's documentation for the recommended NTP server.
If you can't fix the clock (locked-down shared hosting), some 2FA plugins have a "time window" setting. Increase it to 2 or 3 steps (60-90 seconds) to tolerate drift. That weakens security slightly but keeps you from being locked out.
7. REST API authentication failures with cached plugin lists
A headless WordPress setup uses JWT or Application Passwords for REST API auth. Requests return 401 even with correct credentials. You've verified the token, checked wp-json availability, and confirmed the auth plugin is active. The problem: WordPress loads plugins in alphabetical order, and your authentication plugin registers its hook after another plugin already sent headers or output, causing REST API initialization to skip authentication entirely.
Add this to wp-config.php to confirm:
define('WP_DEBUG', true);
define('WP_DEBUG_LOG', true);
Check wp-content/debug.log for "Cannot modify header information" warnings. If you see them, a plugin or theme is echoing content before init. Track it down:
grep -r "^\s*echo\|^\s*print" wp-content/plugins/*/\*.php | head -20
Commenting out the offending plugin temporarily will confirm. The permanent fix is to load your auth plugin earlier by renaming it so it sorts first (rename jwt-auth to aaa-jwt-auth), or use a mu-plugin that registers authentication hooks before regular plugins load.
8. Privilege escalation via unvalidated wp-admin AJAX actions
Custom plugins often register AJAX handlers without capability checks. An attacker calls admin-ajax.php?action=your_custom_action as a Subscriber and triggers admin-level code. I've seen this delete posts, change settings, and even create new admin users.
Audit every add_action('wp_ajax_*') in custom code:
grep -r "add_action.*wp_ajax" wp-content/{plugins,themes}/ | grep -v "wp_ajax_nopriv"
Each handler must call current_user_can() before doing anything:
add_action('wp_ajax_delete_old_posts', 'my_delete_old_posts');
function my_delete_old_posts() {
if (!current_user_can('manage_options')) {
wp_send_json_error('Insufficient permissions', 403);
}
// ... safe to proceed ...
}
If you find a handler without a capability check, add one. Assume every AJAX action is accessible to any logged-in user unless nopriv is in the hook name.
What to check first
When textbook hardening doesn't stop the issue, the root cause is usually environment mismatch—proxy headers, file ownership, time sync, or plugin load order. Start by comparing phpinfo() output to what WordPress believes the environment is (check Site Health under Tools). Look for discrepancies in REMOTE_ADDR, HTTPS, document root, or server time. Those gaps explain most "impossible" security failures.
For permission problems, verify both ownership and the setgid bit. For authentication loops, confirm cookie domain and Secure flag match the actual HTTPS setup. For rate-limiting bypasses, audit which IP header your plugin trusts and whether your proxy stack allows client forgery.
FAQ
Can I set permissions to 0777 temporarily to rule out permission issues?
Yes, but change them back immediately after testing. If 0777 fixes it, the real problem is user/group mismatch, not insufficient permissions. Use the setgid solution instead.
Does Redis expire nonces automatically if I set a TTL?
Redis expires keys after the TTL, but if the TTL is longer than WordPress's nonce lifetime, you get stale nonces. Match Redis TTL to NONCE_LIFE or use versioned cache keys.
How do I know if ModSecurity blocked a request?
Check your server's error log (often /var/log/apache2/error.log or /var/log/nginx/error.log). ModSecurity writes a rule ID like [id "941100"]. If you don't have log access, a 403 with no WordPress error message usually means WAF.
Why does chronyc makestep require root?
It adjusts the system clock. On shared hosting, you can't run it. Contact support or switch to a host that keeps NTP synchronized.
Can I rename a plugin's folder to load it earlier without breaking updates?
Yes, but updates will restore the original name. For permanent load-order changes, use a mu-plugin that loads the functionality before regular plugins.
Where environment mismatches hide
Most advanced security problems stem from WordPress assuming one environment while actually running in another. Proxies lie about IPs, backends see HTTP while users see HTTPS, file operations run as different users, and clocks drift. Before opening a support thread blaming a plugin bug, confirm the server's view of reality matches yours. Nine times out of ten, the fix is a missing header, a wrong permission bit, or a clock nobody synchronized in six months.
