Back to Blog
Security

Implementing Content Security Policy on WordPress: A Practitioner’s Guide

David Okonkwo
40 min read

What Content Security Policy Actually Does (and Why WordPress Makes It Hard)

Content Security Policy is an HTTP response header that tells the browser exactly which sources of content are allowed to execute on a page. If a script, style, image, or other resource does not match the policy, the browser blocks it. This is the single most effective defense against cross-site scripting (XSS) attacks because even if an attacker injects a script tag into your page, the browser refuses to run it.

A minimal CSP header looks like this:

Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self';

That policy tells the browser: only load scripts, styles, and other resources from the same origin as the page. No inline scripts. No inline styles. No eval(). No external CDNs unless explicitly allowed.

The problem with WordPress is that virtually every part of the system assumes inline scripts and styles are acceptable. WordPress core, themes, plugins, and page builders all inject inline JavaScript and CSS directly into the HTML output. A strict CSP policy that blocks inline execution will break most WordPress sites immediately.

This is not a new observation. WordPress Trac ticket #39941, opened back in 2017, proposed adding nonce support to WordPress script output. As of this writing, the ticket remains open. The core team has acknowledged the issue but the changes required are significant. Every call to wp_localize_script() outputs an inline script tag. Every use of wp_add_inline_script() outputs an inline script tag. The Customizer, the block editor, WooCommerce, and nearly every popular plugin injects inline JavaScript.

This guide walks through how to actually implement CSP on a WordPress site. Not the theoretical version where everything is clean and nothing breaks, but the real version where you audit existing inline scripts, decide on a nonce or hash strategy, handle the edge cases from page builders and e-commerce plugins, and roll the policy out gradually so you do not take your site down.

Auditing Inline Scripts on Your WordPress Site

Before you write a single CSP directive, you need to know exactly what inline scripts and styles your site produces. Skipping this step is the most common reason CSP implementations fail. People write a strict policy, deploy it, and then spend days figuring out why their site is broken.

Sources of Inline Scripts in WordPress

WordPress core produces inline scripts through several mechanisms:

wp_localize_script() is the oldest and most widespread. It was originally designed to pass translated strings to JavaScript, but developers have used it for years to pass arbitrary data from PHP to JS. When you call wp_localize_script( 'my-script', 'myData', $data_array ), WordPress outputs an inline script tag in the HTML:

<script type="text/javascript">
/* <![CDATA[ */
var myData = {"ajaxUrl":"\/wp-admin\/admin-ajax.php","nonce":"abc123"};
/* ]]> */
</script>

That inline script has no nonce and no hash. A strict CSP blocks it.

wp_add_inline_script() was introduced in WordPress 4.5 as a more structured way to add inline JavaScript that depends on an enqueued script. It attaches inline code to run before or after a registered script handle. The output is still an inline script tag with no CSP nonce.

wp_add_inline_style() does the same thing for CSS. It outputs an inline style block attached to a registered stylesheet handle.

Raw script tags in templates are everywhere. Themes routinely output <script> tags directly in header.php, footer.php, and individual templates. Google Analytics tracking codes, Facebook pixels, schema.org JSON-LD blocks, and custom JavaScript snippets are all typically pasted directly into templates or injected via the wp_head and wp_footer action hooks.

The block editor (Gutenberg) generates inline scripts for block configuration data. The wp_interactivity() system in newer WordPress versions also outputs inline script tags for hydration data.

Plugin output is the wildcard. Contact form plugins, analytics plugins, caching plugins, SEO plugins, and virtually every other category of plugin may output inline scripts and styles.

How to Perform the Audit

The most reliable audit method is to load every major page template on your site and search the HTML source for <script tags that do not have a src attribute. Those are your inline scripts. Do the same for <style tags.

You can automate part of this with a simple shell command against your rendered pages:

curl -s https://your-site.com/ | grep -oP '<script(?![^>]*src)[^>]*>' | wc -l

That gives you a count of inline script tags on your homepage. Run it against your key pages: homepage, blog archive, single posts, WooCommerce shop, cart, checkout, and any custom page templates.

For a more structured audit, add a temporary mu-plugin that hooks into wp_print_scripts and logs every inline script handle:

add_action( 'wp_print_footer_scripts', function() {
    global $wp_scripts;
    foreach ( $wp_scripts->registered as $handle => $script ) {
        if ( ! empty( $script->extra['after'] ) || ! empty( $script->extra['before'] ) ) {
            error_log( "Inline script attached to handle: $handle" );
        }
        if ( ! empty( $script->extra['data'] ) ) {
            error_log( "wp_localize_script data on handle: $handle" );
        }
    }
}, 1 );

Check your error log after loading a few pages and you will have a clear map of which script handles carry inline code.

Categorizing Your Inline Scripts

Once you have the full list, sort them into categories:

  1. Static inline scripts that never change between page loads. These are candidates for hash-based allowlisting.
  2. Dynamic inline scripts that change per request (nonces, user-specific data, localized strings with variable content). These need nonce-based allowlisting.
  3. Third-party scripts that you cannot modify. These may need to be moved to external files or allowlisted by domain in your CSP.
  4. Scripts you can eliminate. Some inline scripts exist only because the developer took a shortcut. You may be able to refactor them into external files.

Implementing CSP Nonces in WordPress

A CSP nonce is a random, single-use token that you generate on each request. You include the nonce in your CSP header and also add it as an attribute on every legitimate inline script and style tag. The browser then allows inline code that carries the correct nonce and blocks everything else.

Generating a Per-Request Nonce

The nonce must be generated once per request and reused across all script tags on that page. Do not confuse this with WordPress nonces (which are CSRF tokens). CSP nonces are a different concept with the same name.

Create a utility function that generates the nonce once and caches it for the request:

function wpkite_get_csp_nonce() {
    static $nonce = null;
    if ( null === $nonce ) {
        $nonce = bin2hex( random_bytes( 16 ) );
    }
    return $nonce;
}

This produces a 32-character hex string. The static variable ensures the same nonce is returned on every call within a single request.

Adding the Nonce to the CSP Header

Send the CSP header early, before any output. Hook into send_headers or wp_headers:

add_action( 'send_headers', function() {
    if ( is_admin() ) {
        return; // Handle admin separately or skip for now
    }

    $nonce = wpkite_get_csp_nonce();

    $policy = "default-src 'self'; ";
    $policy .= "script-src 'self' 'nonce-{$nonce}'; ";
    $policy .= "style-src 'self' 'nonce-{$nonce}' 'unsafe-inline'; ";
    $policy .= "img-src 'self' data: https:; ";
    $policy .= "font-src 'self' data:; ";
    $policy .= "connect-src 'self'; ";
    $policy .= "frame-src 'self'; ";
    $policy .= "object-src 'none'; ";
    $policy .= "base-uri 'self';";

    header( "Content-Security-Policy: $policy" );
} );

Notice that style-src still includes 'unsafe-inline' as a fallback. Applying nonces to every inline style is substantially harder than scripts because WordPress and plugins generate inline styles in far more places. You can tighten this later. Start with scripts.

Adding the Nonce to Script Tags

WordPress 6.0 introduced the wp_script_attributes filter, which lets you modify the attributes of script tags generated by WP_Scripts. This is the primary hook for adding CSP nonces to enqueued scripts and their inline companions:

add_filter( 'wp_script_attributes', function( $attributes ) {
    $attributes['nonce'] = wpkite_get_csp_nonce();
    return $attributes;
} );

This filter applies to both external script tags (those with a src attribute) and inline script tags generated by wp_add_inline_script(). However, it does not cover wp_localize_script() output in older WordPress versions. In WordPress 6.1 and later, wp_localize_script() output is also routed through the same tag generation pipeline, so the filter applies.

For WordPress versions before 6.1, you need an additional approach. You can use output buffering to inject the nonce into any remaining script tags that the filter does not reach:

add_action( 'template_redirect', function() {
    if ( is_admin() ) {
        return;
    }
    ob_start( function( $html ) {
        $nonce = wpkite_get_csp_nonce();
        // Add nonce to inline script tags that lack one
        $html = preg_replace(
            '/<script(?![^>]*\bnonce=)(?![^>]*\bsrc=)([^>]*)>/',
            '<script nonce="' . esc_attr( $nonce ) . '"$1>',
            $html
        );
        return $html;
    } );
} );

This output buffer callback finds every inline script tag that does not already have a nonce attribute and injects one. It is a brute-force approach but catches everything that filters miss, including scripts output by plugins that bypass the WordPress enqueue system entirely.

The regex specifically targets script tags without a src attribute (inline scripts) that also lack an existing nonce. External scripts referenced by URL do not need a nonce because they are allowed by the 'self' directive in script-src.

Adding the Nonce to Style Tags

For inline styles, WordPress provides the style_loader_tag filter. This filter receives the full HTML tag for each enqueued stylesheet:

add_filter( 'style_loader_tag', function( $tag, $handle, $src ) {
    $nonce = wpkite_get_csp_nonce();
    // Add nonce to link tags
    $tag = str_replace( "<link ", "<link nonce=\"" . esc_attr( $nonce ) . "\" ", $tag );
    return $tag;
}, 10, 3 );

For inline style blocks generated by wp_add_inline_style(), you can extend the output buffer approach to cover style tags as well:

// Inside the same ob_start callback:
$html = preg_replace(
    '/<style(?![^>]*\bnonce=)([^>]*)>/',
    '<style nonce="' . esc_attr( $nonce ) . '"$1>',
    $html
);

Once nonces are applied to all inline styles, you can remove 'unsafe-inline' from style-src in your CSP header. Browsers that support nonces will ignore 'unsafe-inline' when a nonce is present, but older browsers fall back to it. Keeping it in the policy during transition provides backwards compatibility.

Hash-Based Allowlisting for Static Inline Scripts

If you have inline scripts that never change between page loads, you can allowlist them by hash instead of using nonces. This avoids the need to modify the script tags at all. You compute the SHA-256 hash of the script content and include it in the CSP header.

Computing Script Hashes

To get the hash of an inline script, extract the exact content between the opening and closing script tags (including whitespace and newlines) and compute its SHA-256 hash in base64:

echo -n 'var myConfig = {"debug":false};' | openssl dgst -sha256 -binary | openssl base64

That produces something like K7gNU3sdo+OL0wNhqoVWhr3g6s1xYv72ol/pe/Unols=.

You then include it in your CSP header:

script-src 'self' 'sha256-K7gNU3sdo+OL0wNhqoVWhr3g6s1xYv72ol/pe/Unols=';

When to Use Hashes vs. Nonces

Hashes are appropriate when:

  • The script content is truly static and never changes.
  • You want to avoid modifying the HTML output at all.
  • You are allowlisting a small number of known scripts (like a Google Analytics snippet or a JSON-LD block with fixed content).

Nonces are better when:

  • Script content changes per request (localized data, user-specific values).
  • You have many inline scripts and managing individual hashes would be impractical.
  • You want a single mechanism that covers everything.

In practice, most WordPress sites use nonces as the primary mechanism and may add hashes for a handful of third-party scripts they cannot modify.

Automating Hash Collection

You can build a helper that computes hashes for all static inline scripts during development:

function wpkite_compute_script_hashes( $html ) {
    $hashes = array();
    preg_match_all( '/<script(?![^>]*\bsrc=)[^>]*>(.*?)<\/script>/s', $html, $matches );
    foreach ( $matches[1] as $script_content ) {
        $hash = base64_encode( hash( 'sha256', $script_content, true ) );
        $hashes[] = "'sha256-{$hash}'";
    }
    return array_unique( $hashes );
}

Run this against your rendered pages during development to collect the hashes you need. Be aware that any change to the script content (even adding a space) invalidates the hash.

Handling Page Builders and the Block Editor

Page builders are the biggest source of CSP headaches on WordPress sites. They generate markup dynamically and inject inline scripts and styles liberally. Each one presents unique challenges.

Gutenberg (Block Editor)

The block editor injects inline scripts in several places:

Block configuration data: WordPress outputs a wp.blocks configuration object as an inline script. This contains block type definitions, default attributes, and other metadata. Since WordPress 6.0, this output goes through wp_script_attributes, so the nonce filter described above covers it.

Server-side rendered block output: Some blocks output inline styles directly in their rendered HTML using style attributes on individual elements (not style tags). Inline style attributes are controlled by a different CSP directive: style-src-attr. If you want to allow these, add 'unsafe-inline' to style-src-attr or use 'unsafe-hashes' with specific hashes. Most practitioners leave style-src with 'unsafe-inline' during the initial rollout for this reason.

Interactivity API: WordPress 6.5 introduced the Interactivity API, which outputs inline script tags containing JSON state for interactive blocks. These script tags use type="application/json", which browsers do not execute as JavaScript. CSP does not restrict non-executable script types, so these do not need nonces. However, some older Interactivity API implementations used standard script tags, so test your specific WordPress version.

Block styles: Individual blocks can register inline styles via wp_add_inline_style(). The nonce output buffer approach handles these.

Elementor

Elementor is one of the most popular page builders and one of the hardest to make CSP-compatible. It generates:

  • Inline style tags for every widget’s custom styling (colors, spacing, typography).
  • Inline script tags for frontend interactivity (sliders, popups, animations).
  • Dynamic CSS that changes based on responsive breakpoints and user settings.
  • Third-party script loaders for integrations (Google Maps, YouTube embeds, etc.).

The output buffer approach with nonce injection is essentially mandatory for Elementor sites. The alternative is to use Elementor’s built-in “External CSS File” option (found under Elementor > Settings > General), which writes generated CSS to external files instead of inline style tags. This helps with the style side but does nothing for scripts.

For Elementor scripts, you need the output buffer nonce injection plus appropriate CSP directives for any external domains Elementor loads resources from. A typical Elementor CSP might need:

script-src 'self' 'nonce-{$nonce}' https://maps.googleapis.com https://www.youtube.com;
style-src 'self' 'nonce-{$nonce}' 'unsafe-inline' https://fonts.googleapis.com;
font-src 'self' https://fonts.gstatic.com;
frame-src 'self' https://www.youtube.com https://www.google.com;

WPBakery (Visual Composer)

WPBakery outputs inline scripts and styles through its shortcode rendering system. Each shortcode may inject its own inline CSS and JS. The output buffer approach covers the nonce injection, but you should also audit which external resources WPBakery loads for its various content elements (carousels, galleries, etc.) and add those domains to the appropriate CSP directives.

General Page Builder Strategy

For any page builder:

  1. Enable the output buffer nonce injection as your baseline.
  2. Use Report-Only mode (covered in the next section) to catch violations you missed.
  3. Build your list of required external domains by reviewing violation reports.
  4. Test every content element the builder offers, not just the ones currently in use on your site. Content editors may add new elements at any time.

Report-Only Mode: Monitoring Without Breaking

The single most important CSP deployment technique is Report-Only mode. Instead of enforcing the policy, the browser sends a report for every violation but still allows the content to load. This lets you see exactly what would break without actually breaking anything.

Setting Up Report-Only

Replace Content-Security-Policy with Content-Security-Policy-Report-Only in your header:

add_action( 'send_headers', function() {
    if ( is_admin() ) {
        return;
    }

    $nonce = wpkite_get_csp_nonce();
    $policy = "default-src 'self'; ";
    $policy .= "script-src 'self' 'nonce-{$nonce}'; ";
    $policy .= "style-src 'self' 'nonce-{$nonce}'; ";
    $policy .= "img-src 'self' data: https:; ";
    $policy .= "font-src 'self' data:; ";
    $policy .= "connect-src 'self'; ";
    $policy .= "object-src 'none'; ";
    $policy .= "base-uri 'self'; ";
    $policy .= "report-uri /csp-report-endpoint;";

    header( "Content-Security-Policy-Report-Only: $policy" );
} );

Building a Report Endpoint

When a browser detects a violation, it sends a POST request containing a JSON report to the URL specified in report-uri. You need an endpoint to receive and store these reports.

Create a simple report collector as a custom REST API endpoint:

add_action( 'rest_api_init', function() {
    register_rest_route( 'wpkite/v1', '/csp-report', array(
        'methods'             => 'POST',
        'callback'            => 'wpkite_handle_csp_report',
        'permission_callback' => '__return_true',
    ) );
} );

function wpkite_handle_csp_report( WP_REST_Request $request ) {
    $body = $request->get_body();
    $report = json_decode( $body, true );

    if ( ! $report ) {
        return new WP_REST_Response( 'Invalid report', 400 );
    }

    // Extract the actual violation data
    $violation = isset( $report['csp-report'] ) ? $report['csp-report'] : $report;

    // Log to a custom table or file
    error_log( sprintf(
        'CSP Violation: directive=%s blocked=%s source=%s',
        $violation['violated-directive'] ?? 'unknown',
        $violation['blocked-uri'] ?? 'unknown',
        $violation['source-file'] ?? 'unknown'
    ) );

    return new WP_REST_Response( 'OK', 204 );
}

Then update your CSP header to point to this endpoint:

$policy .= "report-uri /wp-json/wpkite/v1/csp-report;";

Understanding Violation Reports

A typical CSP violation report looks like this:

{
    "csp-report": {
        "document-uri": "https://example.com/about/",
        "violated-directive": "script-src",
        "effective-directive": "script-src",
        "original-policy": "default-src 'self'; script-src 'self' 'nonce-abc123'",
        "blocked-uri": "inline",
        "status-code": 200,
        "source-file": "https://example.com/about/",
        "line-number": 142,
        "column-number": 1
    }
}

Key fields to pay attention to:

  • blocked-uri: “inline” means an inline script or style was blocked. A URL means an external resource was blocked.
  • violated-directive: tells you which CSP directive was violated (script-src, style-src, img-src, etc.).
  • source-file and line-number: help you locate the offending code in your page source.
  • document-uri: tells you which page triggered the violation.

Report-URI vs. report-to

The report-uri directive is the older standard and has wide browser support. The newer report-to directive uses the Reporting API and requires a Report-To header to define endpoint groups. Browser support for report-to is growing but not universal. For maximum coverage, include both:

header( 'Report-To: {"group":"csp","max_age":86400,"endpoints":[{"url":"https://your-site.com/wp-json/wpkite/v1/csp-report"}]}' );

// In your CSP:
$policy .= "report-uri /wp-json/wpkite/v1/csp-report; report-to csp;";

External Reporting Services

For production sites with significant traffic, you may want to use a dedicated CSP reporting service rather than handling reports yourself. Services like Report URI (report-uri.com), Sentry, and URI.Report aggregate violations, deduplicate them, and provide dashboards. They handle the volume that a busy site generates (a single page with multiple violations sends multiple reports per page load per visitor).

If you use an external service, simply set their endpoint URL as your report-uri value. No custom endpoint needed on your WordPress installation.

Manual Implementation vs. Plugin Approach

You have two paths for implementing CSP on WordPress: write the code yourself or use a plugin. Both have tradeoffs.

Manual Implementation

Writing CSP code yourself gives you complete control. You know exactly what the policy contains, how nonces are generated, and where they are injected. The code examples in this article form the basis of a manual implementation.

Advantages of manual implementation:

  • No plugin dependency. One fewer thing to update and maintain.
  • Full control over the policy. No plugin-imposed limitations on directive values.
  • Better performance. No plugin overhead, no additional database queries.
  • You understand exactly what is happening. If something breaks, you know where to look.

Disadvantages:

  • You are responsible for maintaining the code when WordPress changes its script output pipeline.
  • No GUI for non-technical team members to adjust the policy.
  • You need to build your own reporting and monitoring.

A manual implementation works well as a must-use plugin. Place it in wp-content/mu-plugins/ so it loads before everything else and cannot be deactivated accidentally:

// wp-content/mu-plugins/csp-policy.php

if ( defined( 'DOING_AJAX' ) || defined( 'DOING_CRON' ) || defined( 'XMLRPC_REQUEST' ) ) {
    return;
}

function wpkite_get_csp_nonce() {
    static $nonce = null;
    if ( null === $nonce ) {
        $nonce = bin2hex( random_bytes( 16 ) );
    }
    return $nonce;
}

add_action( 'send_headers', function() {
    if ( is_admin() ) {
        return;
    }

    $nonce = wpkite_get_csp_nonce();

    $directives = array(
        "default-src 'self'",
        "script-src 'self' 'nonce-{$nonce}'",
        "style-src 'self' 'unsafe-inline'",
        "img-src 'self' data: https:",
        "font-src 'self' data:",
        "connect-src 'self'",
        "frame-src 'self'",
        "object-src 'none'",
        "base-uri 'self'",
    );

    $policy = implode( '; ', $directives );

    // Use Report-Only during rollout, then switch to enforcing
    header( "Content-Security-Policy-Report-Only: $policy" );
} );

add_filter( 'wp_script_attributes', function( $attributes ) {
    $attributes['nonce'] = wpkite_get_csp_nonce();
    return $attributes;
} );

add_action( 'template_redirect', function() {
    if ( is_admin() ) {
        return;
    }
    ob_start( function( $html ) {
        $nonce = wpkite_get_csp_nonce();
        $nonce_attr = esc_attr( $nonce );
        $html = preg_replace(
            '/<script(?![^>]*\bnonce=)(?![^>]*\bsrc=)([^>]*)>/',
            '<script nonce="' . $nonce_attr . '"$1>',
            $html
        );
        return $html;
    } );
} );

Plugin Approach

Several WordPress plugins handle CSP policy generation. The most notable ones:

HTTP Headers: A general-purpose plugin that lets you set various security headers including CSP through the WordPress admin. It provides a form-based interface for building your policy but does not handle nonce injection into script tags. You still need custom code for that part.

CSP Manager: Focused specifically on Content Security Policy. It provides a policy builder and can operate in Report-Only mode. Some versions support nonce injection but the implementation varies.

NinjaFirewall: A web application firewall that includes CSP header support among its security features. It can inject nonces into script tags using output buffering similar to the manual approach described above.

Advantages of using a plugin:

  • Faster initial setup.
  • Admin UI for policy management.
  • May include reporting features.

Disadvantages:

  • Plugin code may conflict with other plugins.
  • You depend on the plugin author to keep up with WordPress core changes.
  • Some plugins use overly permissive default policies that provide little actual security.
  • The plugin itself adds JavaScript and potentially inline scripts to your site.

My recommendation: use the manual mu-plugin approach for the CSP policy and nonce injection. If you want a reporting dashboard, use an external service. The manual approach gives you control without adding another plugin to the stack.

CSP for WooCommerce Sites

WooCommerce adds an entire layer of complexity to CSP implementation. Payment processing, analytics, and third-party integrations all require careful CSP configuration.

Payment Gateways

Payment gateways are the highest-risk area for CSP on WooCommerce sites. If your CSP blocks the payment gateway’s scripts, customers cannot check out. Test thoroughly and consider using a more permissive CSP on checkout pages specifically.

Stripe: The Stripe gateway loads scripts from js.stripe.com and makes API calls to api.stripe.com. It also loads its iframe-based card elements from js.stripe.com. Required CSP additions:

script-src https://js.stripe.com;
frame-src https://js.stripe.com https://hooks.stripe.com;
connect-src https://api.stripe.com;

PayPal: PayPal’s Smart Buttons load from www.paypal.com and related domains. The PayPal SDK is particularly challenging because it uses inline scripts for configuration and loads additional resources dynamically. Required additions:

script-src https://www.paypal.com https://www.paypalobjects.com;
frame-src https://www.paypal.com https://www.sandbox.paypal.com;
connect-src https://www.paypal.com;
img-src https://www.paypalobjects.com https://t.paypal.com;

Square: Square’s Web Payments SDK loads from web.squarecdn.com and connect.squareupsandbox.com (sandbox) or connect.squareup.com (production). Required additions:

script-src https://web.squarecdn.com https://js.squareupsandbox.com;
frame-src https://web.squarecdn.com https://connect.squareupsandbox.com;
connect-src https://pds.squareup.com;

Braintree: Braintree loads from multiple Braintree and PayPal domains. The three.js library it uses for 3D Secure adds additional domains. This is one of the more complex gateways to configure for CSP.

Conditional CSP by Page

A practical approach for WooCommerce is to use a stricter CSP on most pages and a more permissive one on checkout:

add_action( 'send_headers', function() {
    if ( is_admin() ) {
        return;
    }

    $nonce = wpkite_get_csp_nonce();
    $base_directives = array(
        "default-src 'self'",
        "script-src 'self' 'nonce-{$nonce}'",
        "style-src 'self' 'unsafe-inline'",
        "img-src 'self' data: https:",
        "font-src 'self' data:",
        "connect-src 'self'",
        "object-src 'none'",
        "base-uri 'self'",
    );

    // On checkout, add payment gateway domains
    if ( function_exists( 'is_checkout' ) && is_checkout() ) {
        $base_directives[1] = "script-src 'self' 'nonce-{$nonce}' https://js.stripe.com";
        $base_directives[] = "frame-src 'self' https://js.stripe.com https://hooks.stripe.com";
        $base_directives[5] = "connect-src 'self' https://api.stripe.com";
    }

    $policy = implode( '; ', $base_directives );
    header( "Content-Security-Policy: $policy" );
} );

Analytics and Tracking

WooCommerce sites typically run analytics that require additional CSP directives:

Google Analytics (GA4):

script-src https://www.googletagmanager.com https://www.google-analytics.com;
connect-src https://www.google-analytics.com https://analytics.google.com https://region1.google-analytics.com;
img-src https://www.google-analytics.com https://www.googletagmanager.com;

Google Tag Manager: GTM is particularly difficult because it loads arbitrary scripts defined in the GTM container. Each tag you add in GTM may require additional CSP directives. This makes GTM fundamentally incompatible with tight CSP policies unless you audit every tag in the container and allowlist its resources. Some teams choose to load GTM only with the nonce (so the GTM script itself is allowed) and then accept that GTM-injected scripts will be blocked unless they carry the nonce. GTM supports a “nonce” feature where you pass your CSP nonce to the GTM snippet and it propagates to dynamically injected scripts:

// In your GTM snippet:
(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;
j.src='https://www.googletagmanager.com/gtm.js?id='+i+dl;
j.nonce='YOUR_NONCE_HERE';  // Pass CSP nonce
f.parentNode.insertBefore(j,f);
})(window,document,'script','dataLayer','GTM-XXXXX');

Facebook Pixel:

script-src https://connect.facebook.net;
connect-src https://www.facebook.com https://connect.facebook.net;
img-src https://www.facebook.com https://www.facebook.com/tr;

Third-Party Embeds

WooCommerce product pages may embed content from external sources:

  • YouTube/Vimeo videos: Add their domains to frame-src.
  • Google Maps: Add maps.googleapis.com to script-src and frame-src.
  • Social sharing widgets: Each platform (Facebook, Twitter, LinkedIn) has its own domains for script-src and frame-src.
  • Review platforms: Trustpilot, Yotpo, Judge.me all load external scripts and iframes.

The key lesson for WooCommerce CSP: start with Report-Only mode, run it for at least two weeks, and build your allowlist from actual violation reports. Trying to guess every required domain upfront is futile.

Gradual Rollout Strategy for Existing Sites

Deploying CSP on a new site is straightforward. Deploying it on an existing site with years of accumulated plugins, custom code, and third-party integrations requires a phased approach.

Phase 1: Baseline Audit (Week 1-2)

Deploy a very permissive Report-Only policy to establish your baseline:

Content-Security-Policy-Report-Only: default-src * 'unsafe-inline' 'unsafe-eval' data: blob:; report-uri /wp-json/wpkite/v1/csp-report;

This policy allows everything but still generates reports for any resources that would violate a stricter policy. The purpose at this stage is to confirm your reporting infrastructure works and to see what resources your site actually loads.

Review the reports to build a list of all domains and resource types your site uses.

Phase 2: Restrictive Report-Only (Week 3-4)

Write a strict policy based on your audit and deploy it as Report-Only:

Content-Security-Policy-Report-Only: default-src 'self'; script-src 'self' 'nonce-{$nonce}' https://js.stripe.com https://www.googletagmanager.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https://api.stripe.com https://www.google-analytics.com; frame-src 'self' https://js.stripe.com https://www.youtube.com; object-src 'none'; base-uri 'self'; report-uri /wp-json/wpkite/v1/csp-report;

Implement the nonce injection (wp_script_attributes filter and output buffer). Monitor violation reports for two weeks. Fix each violation by either:

  • Adding the nonce to the offending script/style tag.
  • Adding the offending domain to the appropriate CSP directive.
  • Refactoring inline scripts into external files.
  • Removing unnecessary scripts entirely.

Phase 3: Enforcing on Low-Traffic Pages (Week 5-6)

Start enforcing the policy on specific pages that get less traffic and are less critical. Good candidates:

  • The blog archive and single posts (if they do not have WooCommerce elements).
  • Static pages (About, Contact, Privacy Policy).
  • 404 page.

Use conditional logic to enforce on some pages while remaining Report-Only on others:

add_action( 'send_headers', function() {
    $nonce = wpkite_get_csp_nonce();
    $policy = build_csp_policy( $nonce ); // Your policy builder function

    // Enforce on blog pages, report-only elsewhere
    if ( is_singular( 'post' ) || is_home() || is_archive() ) {
        header( "Content-Security-Policy: $policy" );
    } else {
        header( "Content-Security-Policy-Report-Only: $policy" );
    }
} );

Phase 4: Full Enforcement (Week 7+)

Once you have confirmed that enforcing mode causes no issues on low-traffic pages, expand to the full site. Keep the report-uri directive active even in enforcement mode so you catch any new violations introduced by plugin updates, content changes, or new third-party integrations.

Ongoing Maintenance

CSP is not a set-and-forget configuration. You must update your policy when:

  • You install or update a plugin that loads new external resources.
  • You add a new third-party integration (analytics, chat widget, A/B testing).
  • WordPress core changes how it outputs scripts (which happens periodically).
  • Your theme is updated and introduces new inline scripts or external dependencies.

Keep your CSP reporting active permanently. Set up alerts for spikes in violation reports, which may indicate either a broken update or an actual XSS attack being blocked.

Testing and Validating CSP Policies

A CSP policy that looks correct on paper may still break things in practice. Thorough testing is essential.

Browser Developer Tools

Every major browser shows CSP violations in the developer console. Open the Console tab and look for messages like:

Refused to execute inline script because it violates the following
Content Security Policy directive: "script-src 'self' 'nonce-abc123'"

The console tells you exactly which directive was violated and provides the source location. This is the fastest way to debug CSP issues during development.

Chrome also shows the full CSP policy in the Network tab. Click on the page request, scroll to Response Headers, and find the Content-Security-Policy header to verify it contains the values you expect.

CSP Evaluator

Google’s CSP Evaluator (csp-evaluator.withgoogle.com) analyzes your policy and flags weaknesses. It checks for things like:

  • Using 'unsafe-inline' without nonces (which defeats the purpose of CSP for script-src).
  • Overly broad domain allowlisting (like allowing all of *.googleapis.com).
  • Missing object-src or base-uri directives.
  • Allowing data: URIs in script-src (which enables script injection via data URIs).

Run your policy through the evaluator before deploying it. Fix any high-severity findings.

Automated Testing with Headless Browsers

For sites with many pages and templates, automated testing catches issues that manual checking misses. Use a headless browser (Puppeteer or Playwright) to load every major page and collect console errors:

const puppeteer = require('puppeteer');

(async () => {
    const browser = await puppeteer.launch();
    const page = await browser.newPage();

    const violations = [];
    page.on('console', msg => {
        if (msg.text().includes('Content Security Policy')) {
            violations.push({
                url: page.url(),
                message: msg.text()
            });
        }
    });

    const urls = [
        'https://your-site.com/',
        'https://your-site.com/blog/',
        'https://your-site.com/shop/',
        'https://your-site.com/checkout/',
        // Add all major page templates
    ];

    for (const url of urls) {
        await page.goto(url, { waitUntil: 'networkidle0' });
    }

    console.log('Violations found:', violations.length);
    violations.forEach(v => console.log(v.url, v.message));

    await browser.close();
})();

Run this as part of your deployment pipeline or as a scheduled check.

Testing Plugin Compatibility

The most common source of post-deployment CSP issues is plugin updates that introduce new inline scripts or external resources. After every plugin update:

  1. Check the browser console for new CSP violations.
  2. Review your CSP violation reports for new entries.
  3. Test any new features the plugin update introduces.

If you use a staging environment (and you should), test plugin updates there first with your CSP policy active before deploying to production.

Testing Admin Area

The WordPress admin area is a special case. Applying a strict CSP to wp-admin is significantly harder than the frontend because the admin loads a huge number of inline scripts for the block editor, plugin settings pages, and core UI components.

Most practitioners start by excluding wp-admin from their CSP entirely (using the is_admin() check shown earlier). The attack surface in wp-admin is lower because only authenticated users with elevated privileges access it. However, if you want to protect admin pages against XSS as well, you will need a separate, more permissive CSP policy for the admin area with additional allowed sources and possibly 'unsafe-inline' as a fallback.

Verifying Nonce Rotation

CSP nonces must change on every page load. If your nonce is accidentally cached (by a page caching plugin, CDN, or server-side cache), the nonce in the CSP header will not match the nonce in the HTML script tags on subsequent requests, and everything breaks.

To verify nonce rotation:

  1. Load a page and note the nonce value in the CSP header (from the Network tab).
  2. Load the same page again (hard refresh, not from browser cache).
  3. Confirm the nonce value has changed.
  4. Check that the nonce in the CSP header matches the nonce attributes on inline script tags in the page source.

If you use a full-page caching plugin like WP Super Cache, W3 Total Cache, or WP Rocket, the cached HTML will contain a stale nonce. You have three options:

  • Fragment caching: Configure your cache to exclude the CSP header and nonce-bearing script tags from the cache. This is complex and not supported by all caching plugins.
  • Disable full-page caching: Not practical for most production sites.
  • Edge-side includes (ESI): If your CDN supports ESI, you can have the nonce generated at the edge for each request while the rest of the page is served from cache. Varnish and some CDN providers support this.
  • Use hashes instead of nonces: Hashes do not change per request, so they work with caching. But this only works for static inline scripts.

The caching conflict is the most technically challenging aspect of CSP on WordPress. For sites that rely heavily on full-page caching, a hash-based approach combined with moving as many inline scripts as possible into external files may be more practical than nonces.

Advanced Considerations

Strict-Dynamic

The 'strict-dynamic' source expression tells the browser: if a script is allowed (by nonce or hash), then any scripts that allowed script loads dynamically are also allowed, regardless of their source. This is useful for scripts that use document.createElement('script') to load additional resources (which is how many analytics and tag management scripts work).

script-src 'nonce-{$nonce}' 'strict-dynamic';

With 'strict-dynamic', you do not need to enumerate every domain that dynamically-loaded scripts come from. The trust propagates from the initial nonce-bearing script. This simplifies your policy significantly for sites with complex third-party script chains.

Note that 'strict-dynamic' causes browsers to ignore 'self' and URL-based allowlists in script-src. All external scripts must be loaded by a nonce-bearing script. This means you need to ensure your main JavaScript files are loaded with the nonce attribute, and they in turn load any additional scripts.

Trusted Types

Trusted Types is a newer browser API that prevents DOM-based XSS by restricting which values can be assigned to dangerous sinks like innerHTML, document.write(), and eval(). You can enable it via CSP:

trusted-types myPolicy; require-trusted-types-for 'script';

WordPress core and most plugins are not compatible with Trusted Types as of this writing. Enabling it will break almost everything. However, it is worth tracking as browser support grows and the WordPress ecosystem gradually adapts. It represents the future direction of client-side XSS prevention.

Subresource Integrity (SRI)

SRI is not part of CSP but complements it. SRI ensures that external scripts and stylesheets have not been tampered with by verifying their content against a cryptographic hash:

<script src="https://cdn.example.com/lib.js"
        integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8w"
        crossorigin="anonymous"></script>

WordPress does not add SRI attributes by default, but you can add them using the script_loader_tag filter:

add_filter( 'script_loader_tag', function( $tag, $handle, $src ) {
    $sri_hashes = array(
        'jquery-core' => 'sha384-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
        // Add hashes for CDN-loaded scripts
    );

    if ( isset( $sri_hashes[ $handle ] ) ) {
        $tag = str_replace( '></script>', ' integrity="' . $sri_hashes[ $handle ] . '" crossorigin="anonymous"></script>', $tag );
    }

    return $tag;
}, 10, 3 );

Using SRI alongside CSP provides defense-in-depth: CSP controls which sources can load, and SRI verifies the content has not been modified.

The wp_content_security_policy Filter

If you are building a CSP solution for distribution (as a plugin or framework), consider creating your own filter that other plugins can hook into to register their required CSP directives:

function wpkite_build_csp_policy( $nonce ) {
    $directives = array(
        'default-src' => array( "'self'" ),
        'script-src'  => array( "'self'", "'nonce-{$nonce}'" ),
        'style-src'   => array( "'self'", "'unsafe-inline'" ),
        'img-src'     => array( "'self'", "data:", "https:" ),
        'font-src'    => array( "'self'", "data:" ),
        'connect-src' => array( "'self'" ),
        'frame-src'   => array( "'self'" ),
        'object-src'  => array( "'none'" ),
        'base-uri'    => array( "'self'" ),
    );

    $directives = apply_filters( 'wpkite_csp_directives', $directives, $nonce );

    $parts = array();
    foreach ( $directives as $directive => $sources ) {
        $parts[] = $directive . ' ' . implode( ' ', array_unique( $sources ) );
    }

    return implode( '; ', $parts );
}

Then a WooCommerce Stripe integration, for example, could add its required domains:

add_filter( 'wpkite_csp_directives', function( $directives, $nonce ) {
    if ( function_exists( 'is_checkout' ) && is_checkout() ) {
        $directives['script-src'][]  = 'https://js.stripe.com';
        $directives['frame-src'][]   = 'https://js.stripe.com';
        $directives['frame-src'][]   = 'https://hooks.stripe.com';
        $directives['connect-src'][] = 'https://api.stripe.com';
    }
    return $directives;
}, 10, 2 );

This pattern makes CSP policy management collaborative and extensible, which is critical on WordPress sites where multiple plugins may need to add their own CSP requirements.

A Complete Reference Implementation

Here is a complete mu-plugin that ties together everything discussed in this article. It handles nonce generation, CSP header output, nonce injection into script and style tags, conditional policies for WooCommerce pages, and violation reporting.

<?php
/**
 * Plugin Name: WPKite CSP Implementation
 * Description: Content Security Policy with nonce injection for WordPress.
 * Version: 1.0.0
 */

// Skip for AJAX, Cron, and XML-RPC requests.
if ( defined( 'DOING_AJAX' ) || defined( 'DOING_CRON' ) || defined( 'XMLRPC_REQUEST' ) || defined( 'REST_REQUEST' ) ) {
    return;
}

/**
 * Generate a per-request CSP nonce.
 */
function wpkite_get_csp_nonce() {
    static $nonce = null;
    if ( null === $nonce ) {
        $nonce = bin2hex( random_bytes( 16 ) );
    }
    return $nonce;
}

/**
 * Build CSP policy directives.
 */
function wpkite_build_csp_directives() {
    $nonce = wpkite_get_csp_nonce();

    $directives = array(
        'default-src' => array( "'self'" ),
        'script-src'  => array( "'self'", "'nonce-{$nonce}'" ),
        'style-src'   => array( "'self'", "'unsafe-inline'" ),
        'img-src'     => array( "'self'", 'data:', 'https:' ),
        'font-src'    => array( "'self'", 'data:' ),
        'connect-src' => array( "'self'" ),
        'frame-src'   => array( "'self'" ),
        'object-src'  => array( "'none'" ),
        'base-uri'    => array( "'self'" ),
    );

    // Allow other plugins to add their CSP requirements.
    $directives = apply_filters( 'wpkite_csp_directives', $directives, $nonce );

    $parts = array();
    foreach ( $directives as $directive => $values ) {
        $parts[] = $directive . ' ' . implode( ' ', array_unique( $values ) );
    }

    return implode( '; ', $parts );
}

/**
 * Send the CSP header.
 */
add_action( 'send_headers', function() {
    if ( is_admin() ) {
        return;
    }

    $policy = wpkite_build_csp_directives();

    // During rollout, use Report-Only. Switch to enforcing when ready.
    // header( "Content-Security-Policy: $policy" );
    header( "Content-Security-Policy-Report-Only: $policy" );
} );

/**
 * Add nonce to script tags via WordPress filter.
 */
add_filter( 'wp_script_attributes', function( $attributes ) {
    if ( ! is_admin() ) {
        $attributes['nonce'] = wpkite_get_csp_nonce();
    }
    return $attributes;
} );

/**
 * Output buffer to inject nonce into remaining inline scripts.
 */
add_action( 'template_redirect', function() {
    if ( is_admin() ) {
        return;
    }
    ob_start( function( $html ) {
        $nonce = esc_attr( wpkite_get_csp_nonce() );

        // Inject nonce into inline script tags missing one.
        $html = preg_replace(
            '/<script(?![^>]*\bnonce=)(?![^>]*\bsrc=)([^>]*)>/',
            '<script nonce="' . $nonce . '"$1>',
            $html
        );

        return $html;
    } );
} );

/**
 * Register CSP report endpoint.
 */
add_action( 'rest_api_init', function() {
    register_rest_route( 'wpkite/v1', '/csp-report', array(
        'methods'             => 'POST',
        'callback'            => function( WP_REST_Request $request ) {
            $body   = $request->get_body();
            $report = json_decode( $body, true );

            if ( ! $report ) {
                return new WP_REST_Response( 'Invalid report', 400 );
            }

            $violation = isset( $report['csp-report'] ) ? $report['csp-report'] : $report;

            error_log( sprintf(
                '[CSP Report] directive=%s blocked=%s document=%s',
                $violation['violated-directive'] ?? 'unknown',
                $violation['blocked-uri'] ?? 'unknown',
                $violation['document-uri'] ?? 'unknown'
            ) );

            return new WP_REST_Response( null, 204 );
        },
        'permission_callback' => '__return_true',
    ) );
} );

To deploy this: save it as wp-content/mu-plugins/wpkite-csp.php. It starts in Report-Only mode. Monitor your error log and CSP reports. When you are confident the policy is correct, uncomment the enforcing header line and comment out the Report-Only line.

Common Pitfalls and How to Avoid Them

After implementing CSP on dozens of WordPress sites, these are the issues that come up repeatedly:

Pitfall 1: Forgetting about page caching. Full-page caching stores the nonce in the cached HTML. The next visitor gets a page with nonce “abc” but the CSP header says nonce “xyz” because the header is generated fresh. Solution: exclude pages from full-page cache, use hashes for static scripts, or implement edge-side nonce injection.

Pitfall 2: Using ‘unsafe-inline’ alongside nonces in script-src. This is a misunderstanding. When a nonce is present in script-src, browsers that support CSP Level 2+ ignore 'unsafe-inline'. But if you include 'unsafe-inline' thinking it is a fallback for older browsers, those older browsers will allow all inline scripts, which defeats the purpose. Only include 'unsafe-inline' in script-src if you explicitly want older browsers to fall back to allowing all inline scripts. For style-src, keeping 'unsafe-inline' alongside nonces is more acceptable because the risk from inline styles is lower than inline scripts.

Pitfall 3: Not testing with all user roles. Logged-in users and administrators see different page output than anonymous visitors. WordPress adds admin bar scripts for logged-in users. Some plugins output different scripts based on user capabilities. Test your CSP with every user role your site supports.

Pitfall 4: Forgetting about 3D Secure and payment redirects. Payment gateways that use 3D Secure (SCA) may open iframes or redirect to bank domains that you have not allowlisted. These domains vary by bank and card issuer. For frame-src, you may need to allowlist broader patterns or accept that some 3D Secure flows will require 'self' https: in frame-src.

Pitfall 5: Breaking wp-login.php. The WordPress login page uses inline scripts for password strength meters and other UI elements. If your CSP applies to the login page and blocks those scripts, users cannot log in. Exclude wp-login.php from your CSP or ensure the nonce injection covers it.

Pitfall 6: Ignoring connect-src. AJAX requests, fetch calls, and WebSocket connections are all governed by connect-src. WordPress AJAX calls go to admin-ajax.php on the same origin, but the REST API, Heartbeat API, and any third-party API calls may need additional domains in connect-src. Missing entries here cause silent failures that are hard to debug because the blocked requests do not show a visible error on the page.

Pitfall 7: Deploying on Friday. CSP issues can break checkout flows, login forms, and critical site functionality. Deploy on Tuesday. Monitor for three business days before the weekend.

Further Reading and Resources

The CSP specification is maintained by the W3C: https://www.w3.org/TR/CSP3/. The MDN documentation on CSP is the best practical reference: https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP. For WordPress-specific CSP issues, WordPress Trac ticket #39941 tracks the ongoing discussion about nonce support in core.

Scott Helme’s securityheaders.com is a free tool that scans any URL and grades its security headers including CSP. It provides actionable suggestions for improvement. Google’s CSP Evaluator (csp-evaluator.withgoogle.com) focuses specifically on analyzing CSP policies for weaknesses.

For keeping up with CSP developments in WordPress, follow the core development blog at make.wordpress.org/core and watch for updates to the script loading pipeline, particularly around the WP_Scripts class and the wp_script_attributes filter.

Content Security Policy is one of the most effective security controls available for web applications, but implementing it on WordPress requires patience and methodical testing. The WordPress ecosystem was not designed with CSP in mind, and retrofitting it takes effort. The payoff is substantial: a properly configured CSP stops XSS attacks cold, even when other defenses fail. Start with Report-Only mode, build your policy from real data, test thoroughly, and roll out gradually. The process described in this guide works for sites ranging from simple blogs to complex WooCommerce stores with dozens of plugins.

Share this article

David Okonkwo

Application security engineer focused on WordPress. OWASP contributor and former penetration tester. Writes about REST API security, authentication, and hardening.