You add the header on a Friday afternoon. Content-Security-Policy: default-src 'self'. The front end loses its web fonts, the block editor loses half its toolbar, the embedded map goes white, and the cookie banner never appears. You take the header back off. That is where most Content Security Policy projects end, roughly eleven minutes after they start.
The header is not difficult because the syntax is difficult. The syntax is a list of directives separated by semicolons, each with a list of allowed sources. It is difficult because a WordPress page is assembled by a few dozen things you did not write, and every one of them assumes it may drop a script tag, a style attribute or an iframe wherever it likes. A policy is a single statement about all of them at once.
So the useful question is not what a perfect policy looks like. It is which parts of your page already violate the policy you would like to have, in what order, and which of those violations you can actually fix.
What the header stops, and what it does not
A Content Security Policy is enforced by the browser, at the moment a resource is about to load or a script is about to run. The browser compares the request against the matching directive. No match, no load, and a console error naming the directive that refused. Nothing about it runs on your server.
That makes it good at one class of problem: script that arrives inside your HTML from somewhere it should not have. A comment or a custom field rendered without escaping, a settings value echoed straight into an admin page, a third party script host that gets compromised and starts serving something new, an inline script inside a file a user uploaded. In each case the payload is already in the page, and the policy is the last thing between it and execution.
It stops none of the other things. Server side code runs before the browser sees a byte, so a backdoor in a theme function is unaffected, a vulnerable plugin still writes its rows, and a stolen administrator password is still an administrator password. A policy allowing 'self' for scripts also allows any .js file sitting on your own domain, including one an attacker wrote into your uploads folder. The header is a second lock on a door you still have to keep shut.
Security header and CSP builder
Build a Content-Security-Policy and the headers that travel with it, with every switch explained: what it controls, what an attacker gets back when you leave it open, and what usually breaks the moment you close it. Everything is worked out in this browser tab, nothing is uploaded, nothing is fetched and no report is sent anywhere.
A starting point, not an answer. Most sites end up somewhere between the first two, and the third one is there to be read, not to be used.
Tightening this breaks whatever you forgot to think about. A plugin that pulls a script from its own CDN simply goes quiet: nothing on the page says so, there is one red line in the console and a support ticket a week later.
Tightening this breaks inline onclick= handlers, every <script> block a theme or plugin prints into the page, and anything that builds code out of a string. The way back in is a nonce, not 'unsafe-inline'.
style= attributes included.
Tightening this breaks the layout before it breaks anything else. WordPress and every page builder write style= attributes straight into the markup, so dropping 'unsafe-inline' here usually costs you the design. Google Fonts needs fonts.googleapis.com in this row, the stylesheet lives on that host.
Tightening this breaks inline images. Base64 pictures and most inline SVG icons need data:, and without it placeholder graphics, editor previews and a lot of plugin icons turn into empty boxes. Avatars and a CDN each need their own host here.
Tightening this breaks the typeface, quietly. Google Fonts serves the files from fonts.gstatic.com, so that host goes here while the stylesheet host goes in style-src. Miss either of the two and the page falls back to a system font with no error a visitor would notice.
Tightening this breaks the analytics beacon first, and it breaks it silently: the page looks perfect and the numbers stop. Plausible, Matomo and GA4 each need their own host in this row, and so does any script talking to a REST API that is not on your domain.
Tightening this breaks embedded video and maps. YouTube needs www.youtube.com or www.youtube-nocookie.com, Vimeo needs player.vimeo.com. Leave the row switched off and default-src decides instead, which with 'self' makes every embed a blank rectangle.
Tightening this breaks your own preview frames, a few page builders and the odd payment provider that frames the checkout on purpose. It is not a fetch directive, so default-src never covers it: leave the row off and there is no restriction at all.
Tightening this breaks a newsletter or checkout form that posts straight to an external provider. Name that host or the submit is refused, and the visitor sees a page that did nothing when they pressed the button. In return, an injected form cannot post the login to somebody else.
Tightening this breaks next to nothing. Hardly any site sets a base tag, while an injected one repoints every relative URL on the page, script sources included, without changing a single line of your markup. This row is the cheapest one in the policy.
Tightening this breaks nothing built this decade. It was a dependable way into a page for the decade before that, which is why 'none' here is the one line every published recommendation agrees on. A PDF shown with <embed> is the one case worth checking.
Tightening this breaks anything that genuinely only exists over plain HTTP: it becomes a failed request instead of an insecure one. In exchange it clears the mixed content warnings out of ten years of old post bodies without touching the database. It takes no sources, so this row is a switch and nothing else.
Each row offers only the keywords that directive actually honours. 'unsafe-inline' and 'unsafe-eval' mean nothing to img-src or connect-src, so they are not offered there. A row switched off is left out of the policy, and for a fetch directive that means default-src decides in its place.
A nonce has to be a fresh random value on every single request, so the policy carries {nonce} as a placeholder and the PHP snippet below fills it in. A real one looks like , drawn here from crypto.getRandomValues. A nonce that never changes is worth exactly as much as no nonce at all.
Worth keeping in mind: 'self' covers everything on your own domain, the uploads folder included. A policy is the second lock on the door, never the first one.
Tightening this breaks nothing while the certificate is valid, and everything for the length of the max-age once it is not. A browser that has seen the header refuses plain HTTP for that long and offers no way past the warning. Start at five minutes, raise it when the site is genuinely HTTPS everywhere. includeSubDomains extends the same promise to every name under the domain, staging and internal hosts included.
preload commits you to this: the domain gets compiled into the browsers themselves. From then on every browser refuses plain HTTP for your domain and for every name under it before it even asks your server, whatever headers you send afterwards, and removal takes months to reach people who have already updated. One subdomain without a certificate, one internal host, one router with a name under your domain, and that name is unreachable. Do not tick this until every name under the domain is on HTTPS and you mean to keep it that way for years.
Referrer-Policy
How much of the current URL is handed over when a visitor clicks through to somewhere else.
Tightening this breaks almost nothing. The one case that does is a server sending the wrong Content-Type, a stylesheet as text/plain for instance, which browsers used to rescue and now refuse. That is an existing bug becoming visible, not a new one. In return, an uploaded file that a browser might have decided to treat as a script stays a download.
X-Frame-Options
The 2010 answer to clickjacking, kept for browsers that never learned frame-ancestors.
A browser that understands frame-ancestors ignores this header completely, so the two only have to agree for the old ones. That also means this header can never make a policy stricter, only a browser without CSP support behave. ALLOW-FROM never worked in Chrome and is not offered here: a list of allowed sites belongs in frame-ancestors, which takes one.
Permissions-Policy
Which browser features this page, and anything it embeds, is allowed to ask for.
Tightening this breaks the features inside your embeds first. A feature you leave unset keeps the browser default, which for most of them means allowed on your own page and blocked inside a third party frame. Blocking one here blocks it for the embed too, so a video player with a fullscreen button needs fullscreen left on, and a hosted payment frame needs payment.
The builder runs entirely in your browser tab. Nothing is uploaded, fetched or reported anywhere, which also means it cannot inspect your site for you: it builds a policy, it does not read one back.
The fallback chain
default-src is not a global default. It is the fallback for one family of directives, the ones that govern fetching a resource: script-src, style-src, img-src, font-src, connect-src, media-src, object-src, worker-src and, through child-src, frame-src. Leave one of those out and the browser uses default-src. Set it and it replaces the fallback for that directive completely, it does not add to it.
Three of the directives people care most about sit outside that family and inherit nothing. frame-ancestors, form-action and base-uri are unset unless you write them out. So default-src 'self' alone does not restrict who may frame your login page, where a form may post, or what a stray <base> tag may do. That last one matters: inject a base tag and every relative script src on the page points at someone else’s host.
Which is why a policy consisting only of default-src lands in one of two states. Tight, as in default-src 'self', and it blocks your fonts, embeds, analytics and every inline block while leaving framing and form posts wide open. Loose, as in default-src 'self' 'unsafe-inline' https:, and it permits any script from any HTTPS host anywhere, which is close to permitting exactly what an attacker wanted. The row per directive layout exists to push you off that fence.
default-src 'self';
script-src 'self' 'nonce-{nonce}';
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;
font-src 'self' https://fonts.gstatic.com;
img-src 'self' data: blob:;
connect-src 'self' https://www.google-analytics.com;
frame-src https://www.youtube-nocookie.com;
frame-ancestors 'self';
form-action 'self';
base-uri 'self';
object-src 'none'
The two keywords that undo the policy
'unsafe-inline' in script-src tells the browser to run any script written directly into the HTML: a <script> block, an onclick attribute, a javascript: URL. Injected script is, by definition, script written directly into the HTML. The keyword does not weaken the policy, it removes the reason the policy exists.
'unsafe-eval' is narrower and still bad. It re-enables eval(), new Function() and string arguments to setTimeout, which is how a payload that arrived as data becomes a payload that runs. Some older template libraries genuinely need it. If one of yours does, that is worth knowing as a fact about your stack rather than as a checkbox you tick to silence an error.
Nonces and hashes
A nonce is the honest way out. You generate a random value per response, put it in the header as 'nonce-abc123' and on every inline script tag you trust as nonce="abc123". The browser runs the tags carrying the matching value and refuses the rest. An injected script cannot carry it because the attacker does not know it, which is the whole trick. It has to be unpredictable and it has to change every response: 16 bytes from random_bytes(), base64 encoded, is the standard shape.
Two consequences follow. A nonce cannot come from a static config file, which is why the builder leaves a literal {nonce} placeholder in the policy, generates a real one in the PHP output, and marks the Apache and nginx blocks with a comment: neither server can produce a per request value. And a nonce is in direct conflict with a full page cache: if the cache serves yesterday’s HTML with yesterday’s nonce, either the header no longer matches and everything breaks, or the header is cached too and the nonce is now a public constant.
Hashes are the alternative for inline blocks that never change. Take the SHA-256 of the exact script text, base64 it, list it as 'sha256-...'. The browser prints the hash it wants in the console error, so you rarely compute one by hand. The catch is that it covers the bytes exactly: one added space, one plugin update, and the block is blocked again. Hashes also do nothing for onclick attributes unless you add 'unsafe-hashes'.
One detail to know before you start: as soon as a nonce or hash appears in script-src, browsers ignore 'unsafe-inline' in that same directive. That is deliberate, so old browsers fall back to the permissive keyword while modern ones enforce the strict one. It also means a policy carrying both is not the compromise it looks like.
What breaks first on a WordPress site
Inline styles go first. Block supports in core turn colour, spacing and typography settings into style attributes and into inline <style> tags printed per block, and theme.json adds more. Without 'unsafe-inline' in style-src the page loads with its layout half applied, which reads as a broken theme rather than as a security header.
Inline scripts go next, and there are more than you expect. Core prints the emoji detection script into the footer of every front end page through wp_print_inline_script_tag(). Every call to wp_localize_script() or wp_add_inline_script() becomes another inline block, and the block editor ships its settings payload that way. Anything core prints through those helpers passes its attributes through the wp_inline_script_attributes filter, so a nonce can be attached in one place. Plugins that echo a script tag straight into wp_footer never touch that filter and stay broken.
// One nonce per request, reused by the header and the filter.
define( 'MY_CSP_NONCE', base64_encode( random_bytes( 16 ) ) );
add_filter( 'wp_inline_script_attributes', function ( $attr ) {
$attr['nonce'] = MY_CSP_NONCE;
return $attr;
} );
Third is the page builder: Elementor, Divi and their relatives emit per widget inline CSS and per page inline JavaScript configuration. Fourth is the Google Fonts pair, which is two directives and not one: the stylesheet comes from fonts.googleapis.com and belongs in style-src, the font files come from fonts.gstatic.com and belong in font-src. Allow only one and the page appears to work until you look at the letterforms.
Then the embeds. A YouTube player, a map and a reCAPTCHA widget are all iframes and all need their host in frame-src. Analytics is usually two directives as well: the tag script under script-src, the beacon it sends under connect-src. And img-src wants data: for the inline icons in admin CSS plus blob: for the media library’s upload previews, which are object URLs rather than files. If images vanish after you set the header, the console names the cause in one line, where the other five layers of image trouble take an afternoon to isolate.
Report-only is the only sane start
Send the same policy as Content-Security-Policy-Report-Only and the browser enforces nothing. It evaluates every request against the policy, logs each violation to the console, and if you supply a report-uri it POSTs a small JSON body naming the document, the violated directive, the blocked URI, and the source file and line number. Both headers can be sent at once, so a strict report-only policy can run alongside a loose enforced one while you work.
Leave it running for a week, admin included, and let real traffic find what you would never click yourself: the checkout, the password reset template, the one landing page with an old embed on it. Two warnings. Browser extensions inject their own scripts into your pages and generate violations you cannot fix, so much of what arrives is noise from other people’s software. And for cross origin blocks the report strips the path and gives you the origin only, which identifies a host and never a file.
The report-only output is generated for all three server configurations from the same policy you built, so the switch from watching to enforcing is a header name and nothing else. The report-uri field feeds that variant only.
frame-ancestors and the header it replaces
X-Frame-Options has two usable values, DENY and SAMEORIGIN. Its third, ALLOW-FROM, was never implemented in Chrome or Safari. frame-ancestors does the job properly: it takes 'none', 'self' or a list of origins, so one partner domain can frame you and nobody else.
Where both headers are present, frame-ancestors wins in every browser that supports it and the old header is ignored. Keeping both is reasonable for very old clients, as long as they agree. They frequently do not: X-Frame-Options: DENY next to frame-ancestors 'self' means your own site can frame your pages in a modern browser and cannot in an ancient one, which produces a bug report nobody can reproduce. The builder flags that disagreement instead of silently picking a winner. One more limit: frame-ancestors, report-uri and sandbox are all ignored when a policy is delivered in a <meta> tag, so framing protection has to be a real header.
HSTS preload is a one way door
Strict-Transport-Security tells a browser to use HTTPS for your host for the next max-age seconds, whatever a link says. That part is reversible: lower the value, wait it out, the memory expires. includeSubDomains extends it to every subdomain, including the staging box, the mail interface and the subdomain whose certificate nobody has renewed in two years. Check that list before ticking it.
preload is the one you cannot take back on your own schedule. Submitting a host to the preload list bakes it into the browser binaries, so a fresh install enforces HTTPS for your domain before it has ever contacted you. Entry requires a max-age of at least one year, includeSubDomains, and an HTTP to HTTPS redirect on the same host. Removal is a request form followed by a wait for browser releases to ship it, realistically months, which is why the option sits behind an explicit warning rather than next to the other checkboxes.
Where the header actually gets set
Three places, and the difference is not cosmetic. A PHP header() call, whether it lives in functions.php or a small must use plugin, is the only one of the three that can produce a per request nonce. It is also the only one that never covers a static file, because a request for a CSS file, a font or an uploaded image is answered by the web server without PHP being involved at all.
That gap is where the SVG case comes in. An SVG is a document format that can carry a <script> element. Loaded through an <img> tag it never executes, which is why the risk is often waved away. Opened directly at its own URL under /wp-content/uploads/ it is a top level document served from your origin, with your cookies, and the script inside it runs. A policy without 'unsafe-inline' in script-src stops that dead, but only if the header is on that response, which means it has to come from the web server. Pair it with the sanitising in why WordPress blocks SVG uploads and how to allow them safely, and confirm which directory your uploads are actually served from before writing the rule.
# Apache: applies to every response the server sends, PHP or not.
<IfModule mod_headers.c>
Header always set Content-Security-Policy "default-src 'self'"
</IfModule>
# nginx: an add_header anywhere in a location block discards
# every add_header inherited from the server block.
add_header Content-Security-Policy "default-src 'self'" always;
The nginx caveat catches people constantly. Header directives do not accumulate down the configuration tree: the moment a location block contains one add_header of its own, every add_header from the enclosing server block stops applying inside it. A site with a separate location block for PHP, or one for static assets with cache headers, quietly loses its security headers there. The always flag solves a different problem, keeping the header on error responses such as a 404 or a 500.
What the builder does not do
It does not read an existing policy back in: one you already run has to be rebuilt row by row, which takes two minutes and forces you to look at every line. It does not hash inline blocks, so a site that prefers hashes to nonces takes the values from the browser console. There is no trusted-types, no split into script-src-elem and script-src-attr, and no report-to, only report-uri, because it is the reporting mechanism that works everywhere.
Some constraints are deliberate teaching. Each row offers only the keywords that directive actually honours, so you cannot tick 'unsafe-eval' on img-src and wonder why nothing changed. A row switched on with nothing ticked emits 'none' rather than an empty value. 'none' typed next to a real host is passed through exactly as written and then flagged, because browsers ignore that combination and seeing it is better than having it quietly corrected. Host fields take a host, a scheme or a wildcard, filtered against a whitelist with no quotes, backslashes or semicolons in it, and anything dropped is named back to you rather than silently discarded.
The reason this header has such a reputation is that it is usually attempted backwards. People take the strictest policy they can find in a blog post, enforce it on production, and discover their site’s actual composition through a support ticket. Started the other way round, in report-only, with a policy that names every directive rather than leaning on default-src, it becomes an inventory: every host this page talks to, every inline block, and the handful with no remaining reason to exist.
Most sites land somewhere honest rather than somewhere perfect. style-src keeps 'unsafe-inline' because WordPress will not stop writing style attributes. script-src gets a nonce and loses 'unsafe-inline', which is the change that actually buys something. object-src 'none', base-uri 'self', form-action 'self' and frame-ancestors cost nothing and close doors default-src never touched. That policy will not score a gold star on a scanner report, and it stops the attack the scanner was worried about.
Treat it like the rest of the boring maintenance: set deliberately, written down, revisited when the site changes. It belongs in the same afternoon as rotating the keys and salts nobody has touched since installation. Neither job is interesting, and both are the difference between a bad day and a very bad week.