Content-Security-Policy Headers in WordPress: XSS Defense with Nonces

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.

Leave Comment

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