Content Security Policy on WordPress
WordPress is the trickiest common platform to deploy a CSP on. Not because the code is hard — it's a 5-line hook — but because the WordPress ecosystem is full of inline scripts, styles and third-party widgets that the CSP has to whitelist. This guide covers the minimal deployment plus the real-world headaches: Gutenberg, plugin conflicts, admin bar, and where to put the code so a theme update doesn't wipe it out.
The minimal deployment
WordPress exposes a send_headers action hook that fires right before any response is sent. Register a callback that calls PHP's header():
add_action('send_headers', function() {
header("Content-Security-Policy-Report-Only: default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:");
});
That's the whole thing. The question is where to put it.
Where to put the code: must-use plugin beats functions.php
You'll see many tutorials say "add this to your theme's functions.php". Don't. Here's why:
- Theme updates silently overwrite
functions.php. Your CSP disappears and you don't notice until something breaks. - Switching themes removes the CSP entirely. That's a surprising security regression for something as small as a theme swap.
- Child themes help (the file survives parent-theme updates) but don't fix the theme-switch problem.
The robust solution is a must-use plugin. Create the directory if it doesn't exist:
mkdir -p wp-content/mu-plugins
Then drop a file called wp-content/mu-plugins/csp.php with:
<?php
/**
* Plugin Name: Content Security Policy
* Description: Sets CSP header sitewide. Survives theme changes and updates.
*/
if (!defined('ABSPATH')) exit;
add_action('send_headers', function() {
header("Content-Security-Policy-Report-Only: default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:");
});
Must-use plugins are loaded automatically — no "activate" step needed — and they're outside the theme/plugin update cycle, so they stay put forever unless you explicitly remove them.
Why WordPress almost always needs 'unsafe-inline'
WordPress core, almost every theme, and most plugins emit inline <script> and <style> blocks all over the page. A quick list of offenders:
- Core injects inline translations via
wp-i18n, inlinetinymceconfig in the admin, and inlinejQuerytriggers - Gutenberg (the block editor) relies extensively on inline React bootstrap scripts
- Plugins like WooCommerce, Contact Form 7, Yoast SEO, Akismet all inject inline JS
- Themes emit inline CSS custom properties for the customizer
Without 'unsafe-inline' in both script-src and style-src, the site breaks immediately. The stricter alternative (nonces) requires a WordPress CSP plugin that filters the output buffer and adds a nonce attribute to every inline tag. This is possible but rare in the wild — it's a non-trivial performance hit for every request.
Pragmatic recommendation: start with 'unsafe-inline', verify the rest of your policy is sensible (no 'unsafe-eval', tight default-src, strict frame-ancestors), and iterate to stricter policies later as specific inline scripts are audited.
'unsafe-inline' is truly required based on what your site actually emits. You get a ready-to-paste CSP for your exact theme/plugin combination.
Common pitfalls
The admin bar shows on the frontend and needs its own origins
When you're logged in, WordPress injects the admin bar — which loads inline scripts, styles, and sometimes Gravatar images from external origins. Test your CSP both logged out AND logged in. The logged-in case typically needs extra whitelists for https://secure.gravatar.com in img-src.
Plugin conflicts on update
A plugin updates, starts loading a new external widget, and suddenly your CSP blocks something that worked yesterday. Keep your CSP in Report-Only for a week after major plugin updates, or subscribe to report-uri notifications so you see violations before users complain.
Gutenberg editor won't load
If you enforce a strict CSP without 'unsafe-inline' in script-src, the Gutenberg block editor will fail to render. Either loosen the policy for /wp-admin/ (see the conditional approach below) or keep 'unsafe-inline'.
Conditional CSP for /wp-admin/ vs. frontend
You can ship a strict CSP on the frontend while loosening it in the admin, where most of the inline-script pain lives:
add_action('send_headers', function() {
if (is_admin()) {
header("Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:");
} else {
header("Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:");
}
});
Note that send_headers may not fire on every admin request path — for maximum coverage, set the admin CSP in admin_init instead:
add_action('admin_init', function() {
header("Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:");
});
Caching plugins strip security headers
Some caching plugins (WP Rocket, W3 Total Cache, LiteSpeed Cache) serve static HTML files bypassing PHP entirely, which means your send_headers hook never fires. If you use such a plugin, deploy the CSP at the web-server level instead (Apache or Nginx) so cached responses still carry the header.
Inline scripts from theme customizer
The WordPress theme customizer writes inline CSS to <style> tags in the header. This requires 'unsafe-inline' in style-src. There is no clean workaround short of using a nonce-based approach.