2026年11月12日

2026年11月12日

WordPress Content Security Policy (CSP): From Report-Only to Nonces

This article is a localized edition of the Japanese original. Read the Japanese original

Content Security Policy (CSP) is an HTTP header that tells the browser where a page may load scripts, styles, images and other resources from. Set up correctly, it makes scripts injected through XSS (cross-site scripting) much harder to run. Set up wrongly, it breaks features of your site. This article shows how to roll it out on WordPress in the following order.

  1. Use Content-Security-Policy-Report-Only to see what would be a violation
  2. Collect violation reports
  3. Apply an allowlist policy
  4. If needed, move to a stricter nonce-based policy

The code assumes WordPress 6.x as of October 2026.

The two approaches

Approach Example Pros Cons
Allowlist script-src 'self' 'unsafe-inline' https://www.googletagmanager.com Existing themes and plugins rarely break With 'unsafe-inline', injected inline scripts also run, so the protection against XSS is limited
Nonce script-src 'nonce-random-value' 'strict-dynamic' Only scripts carrying the correct nonce run, which gives strong protection against XSS Inline scripts and event attributes that cannot carry a nonce stop working. Does not mix well with page caching

The practical path is to start with an allowlist and, once the violations are gone, consider a nonce policy if you need it.

Step 1: Find violations with Report-Only

The Content-Security-Policy-Report-Only header does not block resources that violate the policy; it only reports them. You can see what would be caught without changing how the site behaves.

Put the code in your theme's functions.php, or in a PHP file in wp-content/mu-plugins/ so that it survives a theme change.

<?php
// Send CSP as Report-Only (report violations, block nothing)
add_action( 'send_headers', function () {
	$directives = array(
		"default-src 'self'",
		"script-src 'self' 'unsafe-inline' https://www.googletagmanager.com https://www.google-analytics.com",
		"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
		"img-src 'self' data: https:",
		"font-src 'self' data: https://fonts.gstatic.com",
		"connect-src 'self' https://www.google-analytics.com",
		"frame-src 'self' https://www.youtube.com https://www.youtube-nocookie.com",
		"object-src 'none'",
		"base-uri 'self'",
		"form-action 'self'",
		"frame-ancestors 'self'",
		'report-uri ' . esc_url_raw( rest_url( 'csp/v1/report' ) ),
	);
	header( 'Content-Security-Policy-Report-Only: ' . implode( '; ', $directives ) );
} );

The external domains are examples. Add or remove them to match the services you actually use. report-uri points, via rest_url(), to the endpoint you create in the next step. This produces the correct URL even when the permalink setting is "Plain".

Violations also appear in the "Console" of the browser's developer tools. Open the main pages one by one, such as the home page, posts, pages, the contact form and pages with embedded videos, and check them.

Main directives

Directive What it controls
default-src The default for resource types you do not specify separately
script-src JavaScript
style-src CSS
img-src Images. data: allows inline images and https: allows any image over HTTPS
font-src Web fonts
connect-src Destinations of fetch() and Ajax requests
frame-src What can be embedded in an iframe
object-src <object> and <embed>. 'none' forbids them
base-uri URLs allowed in the <base> tag
form-action Where forms can be submitted
frame-ancestors Sites that may embed your pages in an iframe (clickjacking protection)

If the reports mention eval, a theme or plugin uses eval() or similar. If you need that script, add 'unsafe-eval' to script-src, but security is reduced.

Step 2: Create an endpoint for violation reports

When a violation occurs, the browser POSTs JSON (with the Content-Type application/csp-report) to the URL in report-uri. This example receives it through the WordPress REST API and writes it to the PHP error log.

<?php
// Receive CSP violation reports and write them to the error log
add_action( 'rest_api_init', function () {
	register_rest_route(
		'csp/v1',
		'/report',
		array(
			'methods'             => 'POST',
			'permission_callback' => '__return_true', // Browsers send reports without authentication
			'callback'            => function ( WP_REST_Request $request ) {
				$body = $request->get_body();
				// Do not log oversized submissions
				if ( strlen( $body ) > 0 && strlen( $body ) <= 10000 ) {
					$report = json_decode( $body, true );
					if ( is_array( $report ) && isset( $report['csp-report'] ) ) {
						$r = $report['csp-report'];
						error_log(
							sprintf(
								'CSP violation: %s blocked=%s page=%s',
								$r['violated-directive'] ?? '',
								$r['blocked-uri'] ?? '',
								$r['document-uri'] ?? ''
							)
						);
					}
				}
				return new WP_REST_Response( null, 204 );
			},
		)
	);
} );

The log goes to wp-content/debug.log if WP_DEBUG_LOG is enabled in wp-config.php, and to the server's PHP error log otherwise. Anyone can post to this endpoint, so on a busy site the log grows quickly. When you are done checking, remove report-uri from the policy or delete the endpoint.

Note that newer specifications are replacing report-uri with report-to, but as of October 2026 report-uri still works in the major browsers.

Step 3: Apply an allowlist policy

Using the reports, add the domains you need and remove the ones you do not. Once the main pages no longer produce violations, change the header name to Content-Security-Policy to enforce it.

<?php
// Change only the header name in the code from Step 1
header( 'Content-Security-Policy: ' . implode( '; ', $directives ) );

Keep report-uri for a while after enforcing to monitor violations, and also test features administrators rarely use (registration, payments, search and so on).

Step 4: Move to a nonce policy (advanced)

With a nonce policy, you generate a random value (nonce) for each request and put the same value in both the CSP header and every <script> tag you want to allow. Scripts whose value does not match do not run.

In WordPress, the tags of scripts loaded with wp_enqueue_script() are printed by wp_get_script_tag(), and inline scripts such as those from wp_add_inline_script(), wp_localize_script() and translation data are printed by wp_get_inline_script_tag(). You can add attributes with the wp_script_attributes and wp_inline_script_attributes filters respectively, so that is where you add the nonce. Replacing strings in the script_loader_tag filter misses some inline scripts, such as the data from wp_localize_script(), so use these two filters instead.

<?php
// Nonce-based CSP (try it in Report-Only first)

// Create one nonce per request
function my_csp_nonce() {
	static $nonce = null;
	if ( null === $nonce ) {
		$nonce = base64_encode( random_bytes( 16 ) );
	}
	return $nonce;
}

// Add the nonce to tags of scripts loaded with wp_enqueue_script()
add_filter( 'wp_script_attributes', function ( $attributes ) {
	$attributes['nonce'] = my_csp_nonce();
	return $attributes;
} );

// Add the nonce to inline scripts such as wp_add_inline_script()
add_filter( 'wp_inline_script_attributes', function ( $attributes ) {
	$attributes['nonce'] = my_csp_nonce();
	return $attributes;
} );

add_action( 'send_headers', function () {
	$directives = array(
		"default-src 'self'",
		"script-src 'nonce-" . my_csp_nonce() . "' 'strict-dynamic'",
		"style-src 'self' 'unsafe-inline'",
		"img-src 'self' data: https:",
		"object-src 'none'",
		"base-uri 'self'",
		'report-uri ' . esc_url_raw( rest_url( 'csp/v1/report' ) ),
	);
	header( 'Content-Security-Policy-Report-Only: ' . implode( '; ', $directives ) );
} );

'strict-dynamic' also allows scripts that a nonce-carrying script loads later. You may need it for services such as Google Tag Manager that load other scripts. In browsers that support 'strict-dynamic', any domain allowlist in script-src is ignored.

What stops working with a nonce policy

Once a nonce policy is enforced, the following scripts do not run. Check the reports during the Report-Only phase and deal with them one at a time.

  • Scripts that theme templates or plugins print directly as <script>: rewrite them to use wp_enqueue_script(), wp_add_inline_script() or wp_print_inline_script_tag(). Analytics tags pasted directly into header.php are included
  • Event attributes such as onclick="…" and links that start with javascript:: a nonce cannot allow them. Rewrite them as scripts that use addEventListener()
  • Anything that still relies on 'unsafe-inline' after you add a nonce: when script-src contains a nonce, browsers ignore 'unsafe-inline'. Writing both does not allow inline scripts

How page caching interferes

The nonce must change on every request. If a page caching plugin or a CDN stores the HTML and serves it again, the nonce embedded in the HTML no longer matches the nonce in the header, and scripts stop running. And if caching hands the same nonce to many visitors, the nonce loses its purpose. On a site that uses page caching, either stay with an allowlist policy or confirm that your caching setup supports nonces before you adopt this approach.

Step 5: Check the setup

Use curl to check that the header is sent. Replace example.com with your domain.

curl -sI https://example.com/ | grep -i "content-security-policy"

You can also select the page in the "Network" tab of the browser's developer tools and look at the response headers. To find weaknesses in a policy, paste it into Google's CSP Evaluator.

Caveats

  • send_headers runs when WordPress renders a front-end page. The admin and the login screen do not get the headers from this article
  • If .htaccess or your Nginx configuration also sends a CSP, you end up with two headers. The browser only allows resources that satisfy both policies, which causes unexpected blocking. Set CSP in one place only
  • After adding or updating themes and plugins, check the Report-Only reports and the console for new violations

Summary

  • Start by sending Content-Security-Policy-Report-Only and use the console and the violation reports to find what gets caught
  • Create a report endpoint with register_rest_route() and point report-uri to it with rest_url()
  • An allowlist policy rarely breaks things, but as long as it contains 'unsafe-inline' its protection against XSS is limited
  • For a nonce policy, add the nonce with the wp_script_attributes and wp_inline_script_attributes filters. Directly printed <script> tags, event attributes and javascript: links stop working, and 'unsafe-inline' is ignored
  • On sites with page caching, check compatibility with the cache before adopting a nonce policy

Related articles:

Questions about WordPress? The publisher of this site is based in Japan and replies in Japanese. Contact (Japanese)

お気軽にご相談ください

お見積りへ お問い合わせへ
▲