A Content-Security-Policy header is the single most effective defense against XSS attacks — it tells the browser which scripts, styles, and resources are allowed to execute. Implementing CSP on WordPress requires careful allowlisting because core and plugins rely on inline scripts.
Problem: A WordPress site is missing Content Security Policy headers — or has a permissive default-src 'unsafe-inline' policy — leaving it exposed to cross-site scripting attacks through injected inline scripts.
Solution: Add a Content-Security-Policy header via Nginx or Apache configuration. Start with a Report-Only policy to detect violations without blocking anything, use a nonce-based approach for inline scripts with the wp_script_attributes filter, and tighten the policy incrementally based on violation reports.
The examples below add a CSP header via PHP with a nonce for inline scripts, collect Gutenberg-specific allowlists, and show how to run CSP in report-only mode first to catch violations without breaking the site.
<?php
// Generate a fresh nonce per request and pass it to WordPress scripts
add_action( 'init', function() {
if ( is_admin() ) return; // CSP is complex in admin; handle separately
$GLOBALS['csp_nonce'] = base64_encode( random_bytes( 16 ) );
} );
// Output the CSP header
add_action( 'send_headers', function() {
if ( is_admin() ) return;
$nonce = $GLOBALS['csp_nonce'] ?? '';
$policy = implode( '; ', [
"default-src 'self'",
"script-src 'self' 'nonce-{$nonce}' https://www.googletagmanager.com",
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com", // unsafe-inline needed for WP block styles
"font-src 'self' https://fonts.gstatic.com",
"img-src 'self' data: https:",
"connect-src 'self'",
"frame-src 'none'",
"base-uri 'self'",
"form-action 'self'",
"upgrade-insecure-requests",
] );
header( "Content-Security-Policy: {$policy}" );
} );
// Inject the nonce into every script tag WordPress outputs
add_filter( 'script_loader_tag', function( string $tag, string $handle ) {
$nonce = $GLOBALS['csp_nonce'] ?? '';
if ( empty( $nonce ) ) return $tag;
return str_replace( '<script ', "<script nonce='{$nonce}' ", $tag );
}, 10, 2 );
Start with Report-Only mode to discover violations before enforcing:
<?php
// Report-Only mode — browser reports violations but does NOT block anything
// Use this for 2–4 weeks before switching to enforcing mode
add_action( 'send_headers', function() {
$nonce = $GLOBALS['csp_nonce'] ?? '';
$policy = "default-src 'self'; script-src 'self' 'nonce-{$nonce}'; report-uri /csp-report.php";
// Use Content-Security-Policy-Report-Only instead of Content-Security-Policy
header( "Content-Security-Policy-Report-Only: {$policy}" );
} );
// csp-report.php — collect violation reports
// Validate the request before processing
$raw = file_get_contents( 'php://input' );
$report = json_decode( $raw, true );
if ( isset( $report['csp-report'] ) ) {
$blocked_uri = sanitize_text_field( $report['csp-report']['blocked-uri'] ?? 'unknown' );
$violated_dir = sanitize_text_field( $report['csp-report']['violated-directive'] ?? 'unknown' );
$document_uri = sanitize_text_field( $report['csp-report']['document-uri'] ?? 'unknown' );
// Log to a file (not to DB to avoid log injection)
error_log( "[CSP] {$violated_dir} | blocked: {$blocked_uri} | page: {$document_uri}" );
}
http_response_code( 204 );
NOTE: Gutenberg's block editor requires 'unsafe-eval' in the admin CSP because it uses eval() internally — so a strict CSP is most practical on the front end only. Use the is_admin() check to apply different policies to front and back end.