Projects in Code, Craft, and Curiosity

GrimShadow

Fix Missing Security Headers in WordPress with Jetpack Boost Cache on

Problem: Jetpack Boost Cache was causing missing security headers. Long story short my site kept “losing” security headers whenever Jetpack Boost served a cached page, and .htaccess rules wouldn’t stick. The fix that I found was to set headers at the earliest possible time which was at the PHP layer using auto_prepend_file (via .user.ini), plus a tiny MU plugin to cover /wp-admin. As adding the headers like that in wp_admin bricked all of the admin pages. Now I get A+ on securityheaders.com with or without cache hits. More security analysis and updates coming soon


The problem

  • Security scanners said my site was missing headers like:
    • Strict-Transport-Security
    • Content-Security-Policy: upgrade-insecure-requests
    • X-Content-Type-Options: nosniff
    • Referrer-Policy
    • Permissions-Policy
    • X-Frame-Options
  • Curls looked inconsistent:
    • http://… (301) showed headers.
    • https://… (200) sometimes showed no headers.
    • When X-Jetpack-Boost-Cache: hit appeared, headers often disappeared.

Why? Well (after much diagnostics) apparently Jetpack Boost serves cached HTML via the advanced-cache drop-in before WordPress hooks (like send_headers) and outside the directory where your .htaccess lives. That means:

  • Per-directory .htaccess rules did not run for cached hits.
  • WP hooks may not fire on cached hits.
  • On some hosts, the HTTPS vhost doesn’t honor .htaccess header directives (or blocks advanced expr syntax), leading to 500s when I tried clever conditionals.

What didn’t work (or only partially worked)

  • .htaccess header rules at the web root: worked for some paths (e.g., HTTP → 301), but not reliably for HTTPS 200 responses and not for Boost cached pages.
  • .htaccess in the Boost cache folders: didn’t help; Boost serves the HTML through PHP, not by mapping URLs into that directory.
  • Only using a WP plugin with send_headers: worked for dynamic pages, but not for Boost cached hits.

Disabling boost cache was the easiest “fix” but I wanted to be able to use caching. Using a different caching provider is always an option, but I have been using Boost caching and I use it for other features. Managing settings on another set of plugins trying to get me to pay an exorbitant amount doesn’t sound fun.


How to fix missing security headers in WordPress with Jetpack Boost enabled

If you don’t care about the how and just want the fix. Click the button below and install it as you would a standard plugin for your WP site. Upload the ZIP in Plugins → Add New → Upload.

1) Add a global PHP “prepend” (covers everything, even cache hits)

Create wp-content/shm-prepend.php:
<?php
// Skip admin/login here (we’ll add admin headers via MU plugin).
$__uri = $_SERVER['REQUEST_URI'] ?? '';
$__script = $_SERVER['SCRIPT_NAME'] ?? '';
if (strpos($__uri, '/wp-admin/') !== false || strpos($__script, 'wp-login.php') !== false) {
    return;
}

if (!function_exists('sh_set_header_once')) {
    function sh_set_header_once($name, $value) {
        if (function_exists('headers_list')) {
            foreach (headers_list() as $h) {
                if (stripos($h, $name . ':') === 0) return;
            }
        }
        header($name . ': ' . $value);
    }
}

$is_https = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on')
         || (!empty($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https');

if ($is_https) {
    sh_set_header_once('Strict-Transport-Security', 'max-age=2592000');
}

sh_set_header_once('Content-Security-Policy', 'upgrade-insecure-requests');
sh_set_header_once('X-Content-Type-Options', 'nosniff');
sh_set_header_once('Referrer-Policy', 'strict-origin-when-cross-origin');
sh_set_header_once('Permissions-Policy', 'accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=(), bluetooth=(), fullscreen=(self)');
sh_set_header_once('X-Frame-Options', 'SAMEORIGIN');
// Optional if a scanner nags:
// sh_set_header_once('X-XSS-Protection', '1; mode=block');

Create or edit .user.ini in your WordPress root (same folder as wp-config.php):

auto_prepend_file=wp-content/shm-prepend.php

Use the relative path; it avoids open_basedir issues on shared hosts.

2) Exclude /wp-admin from prepend and add an MU fallback (fixes admin 500s)

Create wp-admin/.user.ini:

auto_prepend_file=

This disables the prepend for the entire admin area.

Now add a tiny MU plugin to send the same headers for admin and login:

Create wp-content/mu-plugins/security-headers-admin.php (create the mu-plugins folder if needed):
<?php
add_action('send_headers', function () {
    $uri = $_SERVER['REQUEST_URI'] ?? '';
    $scr = $_SERVER['SCRIPT_NAME'] ?? '';
    $is_admin = (strpos($uri, '/wp-admin/') !== false) || (strpos($scr, 'wp-login.php') !== false);
    if (!$is_admin) return;

    $is_https = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on')
             || (!empty($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https');

    if ($is_https) header('Strict-Transport-Security: max-age=2592000');
    header('Content-Security-Policy: upgrade-insecure-requests');
    header('X-Content-Type-Options: nosniff');
    header('Referrer-Policy: strict-origin-when-cross-origin');
    header('Permissions-Policy: accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=(), bluetooth=(), fullscreen=(self)');
    header('X-Frame-Options: SAMEORIGIN');
    // header('X-XSS-Protection: 1; mode=block');
}, 10);

Now:

  • Front-end + cached pages → headers via prepend (early, cache-proof)
  • Admin/login → headers via MU plugin (late, but stable)

How I tested

# Home page (often cached)
curl -s -D - -o /dev/null https://your-site.tld/ | sed -n '1,25p'
# Expect: X-Jetpack-Boost-Cache: hit (if using Boost) and all headers

# Dynamic endpoint (bypasses page cache)
curl -I https://your-site.tld/wp-json/
# Expect: all headers

# Redirect path sanity check
curl -I -L http://your-site.tld/
# Expect: headers on the 301 and final 200

# Admin (should NOT 500 now)
curl -I -k https://your-site.tld/wp-admin/

When everything’s green, run external scanners:

SecurityHeaders.com A+ grade after fixing the Jetpack Boost Cache Security Headers issue

HSTS rollout (do this last)

Start with a short HSTS (e.g., 30 days) as above. After you’re 100% sure all your subdomains are HTTPS-only, upgrade to:

Strict-Transport-Security: max-age=31536000; includeSubDomains; preload

Only flip to preload when you’re committed—browsers will enforce HTTPS for your entire zone.


Common pitfalls & quick fixes

  • 500 after adding .user.ini
    Path or permissions. Use a relative path to the prepend file (auto_prepend_file=wp-content/shm-prepend.php). Ensure file perms are 0644.
  • Ensure wp-content/shm-prepend.php is 0644 and owned by the web user to prevent errors
  • Admin 500s but front-end fine
    Add wp-admin/.user.ini (empty auto_prepend_file=) and use the MU plugin shown above.
  • Headers still missing on cached hits
    If prepend is correct and readable, cached hits will include headers. Re-check with curl -s -D - -o /dev/null and confirm X-Jetpack-Boost-Cache: hit appears alongside your headers.

Final checklist

  • wp-content/shm-prepend.php exists (0644)
  • .user.ini in WP root points to wp-content/shm-prepend.php
  • wp-admin/.user.ini disables prepend in admin
  • wp-content/mu-plugins/security-headers-admin.php sets headers for admin/login
  • Curls show headers on /, /wp-json/, and /wp-admin/
  • External scans (securityheaders.com, SSL Labs, Mozilla Observatory) are happy

Why this approach worked

Security headers must be added at response time. When a page cache (like Jetpack Boost) serves HTML before WordPress runs, your normal plugin hooks and some .htaccess rules won’t fire. By setting headers in a PHP file that runs before anything else (auto_prepend_file), you guarantee headers for every PHP response—cached or not—then add a minimal MU plugin for wp-admin stability. Simple, reliable, and easy to keep green.


Want my code as a plugin? I also packaged this into a small “Security Headers Manager” plugin with toggles for prepend, admin exclusion, and MU fallback. as well as a readout of your current headers. If you’d like that:

If there is any issue with this method that I am unaware of please yell at me in the comments down below, or email me all the info is on my About page.

Comments

Leave a Reply