A Content Security Policy (CSP) HTTP response header instructs the browser to block inline scripts, external resource loads, and eval calls that are not explicitly allowed — reducing the impact of XSS attacks to near zero even when an injection point exists in the page. WordPress, however, ships with significant inline script and style usage in the admin panel, the block editor, and many plugins, which makes a strict CSP (default-src 'self') break core functionality immediately. The practical path is to start with a report-only policy using Content-Security-Policy-Report-Only, collect violations via report-to pointing to a logging endpoint, and iteratively tighten the policy over 2–4 weeks before switching to enforcement mode. For the frontend (non-admin) pages, a workable starting policy allows self, your CDN domain, Google Fonts, and Google Analytics as sources, uses nonces for WordPress’s own inline scripts (the wp_add_inline_script output), and disables unsafe-eval and unsafe-inline. WordPress 6.3+ introduced CSP nonce support in wp_scripts via the wp_script_attributes filter — each inline script gets a cryptographically random nonce added to the <script> tag and listed in the policy header, which allows the inline script without unsafe-inline. The nonce must be regenerated on every request and never reused — a static nonce defeats the purpose of CSP. The CSP header is set via PHP in a send_headers hook using header(), or via the web server config (Apache Header directive, Nginx add_header) — using the web server is preferable because it applies even to cached pages served by a reverse proxy. The frame-ancestors directive replaces the deprecated X-Frame-Options header and controls which origins can embed the page in an iframe — set it to 'self' to prevent clickjacking. The wp-config.php hardening post covers the server-level and file-permission layer; CSP adds the browser-enforced application layer on top.
Problem: A WordPress site passes a third-party security scan but has no browser-enforced XSS mitigation — an attacker who finds an XSS injection point can execute arbitrary JavaScript in visitors’ browsers to steal session cookies or redirect to phishing pages.
Solution: Deploy a Content-Security-Policy-Report-Only header first to collect violations without breaking the site, use nonces for WordPress inline scripts, configure frame-ancestors and upgrade-insecure-requests, then promote to enforcement mode after resolving all violations.
// functions.php — send CSP header on frontend pages only
add_action('send_headers', function() {
if (is_admin()) return;
// Generate a cryptographically random nonce per request
$nonce = base64_encode(random_bytes(16));
// Store nonce so wp_script_attributes filter can add it to inline scripts
define('MY_CSP_NONCE', $nonce);
$policy = implode('; ', [
"default-src 'self'",
"script-src 'self' 'nonce-{$nonce}' https://www.googletagmanager.com https://www.google-analytics.com",
"style-src 'self' 'nonce-{$nonce}' https://fonts.googleapis.com",
"font-src 'self' https://fonts.gstatic.com",
"img-src 'self' data: https://www.google-analytics.com https://stats.g.doubleclick.net",
"connect-src 'self' https://www.google-analytics.com",
"frame-ancestors 'self'",
"upgrade-insecure-requests",
"report-uri /wp-json/myplugin/v1/csp-report",
]);
// Start with Report-Only to collect violations without breaking the site
header('Content-Security-Policy-Report-Only: ' . $policy);
// Switch to enforcement after verifying no legitimate sources are blocked:
// header('Content-Security-Policy: ' . $policy);
});
// Add nonce to all inline scripts output by WordPress
add_filter('wp_script_attributes', function(array $attributes): array {
if (defined('MY_CSP_NONCE') && isset($attributes['id'])) {
$attributes['nonce'] = MY_CSP_NONCE;
}
return $attributes;
});
// CSP violation reporting endpoint
add_action('rest_api_init', function() {
register_rest_route('myplugin/v1', '/csp-report', [
'methods' => 'POST',
'permission_callback' => '__return_true',
'callback' => function(WP_REST_Request $request): WP_REST_Response {
$body = $request->get_body();
// Log only — never echo back user-supplied content
error_log('CSP violation: ' . wp_strip_all_tags($body));
return new WP_REST_Response(null, 204);
},
]);
});
# .htaccess — additional security headers (complement CSP set in PHP)
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"
Header always set Permissions-Policy "camera=(), microphone=(), geolocation=()"
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
NOTE: The Strict-Transport-Security (HSTS) header with preload submits your domain to browser preload lists — once submitted, browsers will refuse HTTP connections to your domain even before the first HTTPS redirect. Only add preload when you are certain the entire domain and all subdomains will serve HTTPS permanently; removing it from preload lists takes months. Start with max-age=300 (5 minutes) for testing, increase to max-age=31536000 only after confirming HTTPS works flawlessly across all pages.