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-pluginsThen 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.Do you need a WordPress CSP plugin?
There are plugins that promise to handle a Content Security Policy for you. Before installing one, it is worth knowing what they can and cannot do — because for the common case, the ten lines of code above are less work and fewer moving parts.
What a plugin buys you
- A settings screen. Editing directives in wp-admin instead of a PHP file. Convenient if several people maintain the site, or if nobody on the team is comfortable with
functions.php. - Violation reports. Some collect
report-uridata and display it in the dashboard, which saves setting up an external endpoint. - Nonce injection. The genuinely hard part — giving every inline
<script>a nonce, so the policy can drop'unsafe-inline'. Only a few plugins attempt this, and how they do it matters a great deal.
Strict CSP: the one that does it properly
The Strict CSP plugin is worth knowing about, because it takes the route that actually works. Instead of buffering the finished HTML and rewriting every tag it finds, it hooks into WP_Scripts — the core API that themes and plugins are supposed to use when they register scripts. WordPress core made this possible in Trac #58664 by removing the last places where core assembled <script> tags by hand.
That design decides both its strengths and its limits:
- No output buffering. Nonces are attached where the script is registered, not by parsing HTML afterwards. That is the difference between a few function calls and re-reading the entire page on every request.
- Frontend and login screen only. wp-admin is not covered yet (#59446), because the admin still emits inline scripts the API cannot reach.
- It only protects what plays by the rules. A theme or plugin that prints
<script>directly withechoinstead of usingwp_add_inline_script()gets no nonce — and its script stops working. Some bundled core themes still do this (#63806).
That last point is the honest catch. Strict CSP does not make a site strict-CSP-ready; it reveals whether it already is. On a site with a handful of well-behaved plugins it works. On a site with fifteen plugins of mixed vintage, expect to spend an afternoon finding out which ones print their scripts by hand.
What a plugin costs you
Plugins that do rewrite the finished HTML — the output-buffering approach — pay for it on every request, and they interact badly with page caching: a cached page keeps the nonce it was generated with, while the header sends a fresh one. The result is a policy that blocks its own scripts. If a plugin advertises nonce support, it is worth checking which of the two routes it takes.
Either way, a plugin is one more thing that updates, breaks, or gets abandoned. A security header that silently stops being sent after an update is worse than one that was never set, because nobody notices.
When a plugin is the right call
If you genuinely need nonce-based CSP — a site handling payments or personal data, where 'unsafe-inline' is not acceptable — then Strict CSP is the most sensible starting point, provided your themes and plugins register their scripts the way WordPress intends. Budget time for finding the ones that don't.
For everything else: the must-use plugin from the previous section sends the same header with none of the overhead, cannot be deactivated by accident, and survives theme changes.
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.