A WordPress Content Security Policy That Does Not Break The Site.
A stock WordPress page carries nine inline script blocks. That is why our own CSP still allows unsafe-inline, and here is the honest path forward.
Short answer: deploy the four low-risk directives today, run everything else in report-only mode for a week first, and accept that a wordpress content security policy will probably keep 'unsafe-inline' for scripts unless you control the whole stack. That last part is a real constraint of the platform, not a failure of effort.
Our own policy still has it. Here it is.
Our CSP, including the weak part

nothing. The two red ones are the compromise, and they mean this policy stops an injected external script but not an injected inline one.*
I could have written this post without showing that. It would have been less useful.
'unsafe-inline' in script-src substantially weakens a CSP, because the most common injection is an inline block. Keeping it is a deliberate trade, and knowing you have made that trade is different from believing you are protected.
Why WordPress makes this hard

them, and every plugin adds more.*
| What the page contains | Count | Effect on CSP |
|---|---|---|
inline blocks with no src | 9 | blocked unless you allow inline, or nonce every one |
| external script tags | 12 | fine, 'self' and host allowlists handle these well |
inline style="..." attributes | 13 | blocked by a strict style-src, come from core and themes |
To remove 'unsafe-inline' you must add a nonce to every inline block. A nonce has to be unique per request, which means the HTML cannot be reused between visitors.
That is the collision worth understanding: a strict CSP and a full-page cache pull in opposite directions. You can solve it, with edge-side nonce injection or hashes for a fixed set of blocks, but it stops being a header change and becomes an architecture decision.
For most agency sites the honest answer is to keep 'unsafe-inline' for scripts, take everything else, and be clear with the client about what that does and does not cover.
Which WordPress content security policy directives to set first

genuine protection, need no inventory, and I have not seen any of them break an ordinary WordPress site.*
| Directive | Breakage risk | What it stops |
|---|---|---|
object-src 'none' | near zero | Flash and legacy plugin embeds, nothing in 2026 needs them |
base-uri 'self' | near zero | an injected tag rewriting every relative URL |
form-action 'self' | low | a form retargeted to post credentials elsewhere |
frame-ancestors 'self' | low | clickjacking, supersedes X-Frame-Options |
upgrade-insecure-requests | low | mixed content, rewrites HTTP subresources to HTTPS |
img-src / font-src | medium | needs a real inventory of hosts first |
script-src without unsafe-inline | high | the prize, and the hardest to reach on WordPress |
base-uri 'self' is the one that surprises people. Without it, an injected tag rewrites every relative URL on the page, which quietly redirects form posts and asset loads to somebody else's server. One directive, no downside.
Re-reading our own policy found a fourth wildcard, not three
I re-read protuno.com's live CSP header before expanding this post, rather than reuse the original screenshot, and counted the wildcard entries specifically since this post's closing section names them as the part worth tightening first.
curl -sI https://protuno.com/ | grep -i content-security-policy \
| grep -oE 'https://\*\.[a-zA-Z0-9.-]+|wss://\*\.[a-zA-Z0-9.-]+'
https://*.gleap.io
https://*.gravatar.com
https://*.posthog.com
wss://*.gleap.io
Four distinct wildcard patterns today, not the three this post's original section names, one more added since first publishing, https://.gravatar.com covering avatar images. That is a small, honest correction rather than a dramatic finding, and it is worth making anyway: a policy described as having a fixed number of accepted-risk wildcards drifts the same way every other configuration on this blog drifts, quietly, as new integrations get added, and the count is only accurate the day someone actually reads the header again. wss://.gleap.io is worth a specific note too, a wildcarded WebSocket connection endpoint alongside the HTTPS one for the same vendor, which is easy to miss when scanning a long connect-src line for https:// patterns specifically and skipping the wss:// scheme sitting in the same directive.
Real page complexity versus the "stock" baseline this post measures against
The inline-script and inline-style counts earlier in this post describe a stock WordPress page, deliberately minimal, to isolate what core and a bare theme contribute on their own. I counted the same two things on a real, actively used WordPress page, theplusaddons.com's own homepage, built with Elementor and carrying real content, to see how far a genuine production page departs from that baseline.
grep -oE '<script(?![^>]*src=)[^>]*>' page.html | wc -l
grep -oE 'style="' page.html | wc -l
| Stock WordPress page (this post's baseline) | Real Elementor homepage | |
|---|---|---|
inline blocks | 9 | 8 |
| external script tags | 12 | 55 |
inline style="..." attributes | 13 | 437 |
Inline script count lands close to the stock baseline either way, WordPress core and common plugins are the main source of those regardless of page complexity. Inline style attributes are a different story entirely: 437 on a real, page-builder-constructed homepage against 13 on a bare install, a thirty-fold increase. Page builders like Elementor generate a large share of their layout and styling as inline style attributes directly on elements, which means the style-src half of this post's 'unsafe-inline' trade-off is not a minor concession on a site built this way. It is close to structurally required, because removing it would mean nonce-ing or hash-listing several hundred distinct inline style occurrences per page, regenerated on every edit a client makes in the page builder. Knowing that scale before promising a client a strict style-src is the difference between a realistic rollout plan and a commitment nobody can keep.
The rollout

maps, chat widgets and video players all load from hosts that never appear on the pages you happen to test.*
A week is the minimum for step three, and it needs to span a full business cycle. On a store that means at least one real payment journey and one refund. On a lead-generation site it means the forms, the calendar embed and whatever the marketing team added without telling anybody.
Verify what you shipped, because a header you did not check is not a header:
curl -sI https://example.com/ | grep -i content-security-policy
Reading the reports without drowning
Step three produces volume, and most of it is noise. Three things make it manageable.
Expect browser-extension noise. A large share of violation reports come from extensions injecting scripts into the page. They are not your site and you cannot fix them. Filter by the blocked URI: anything from a chrome-extension:// or moz-extension:// scheme is discardable.
Group by directive, not by report. Ten thousand reports usually collapse into six or seven distinct causes. Sort by effective-directive and blocked-uri and the real list is short.
Watch for the ones that only appear on one page. A payment return page or an embedded map may generate a single violation across a whole week, and that single report is the one that would have broken a checkout.
If you have no infrastructure for collecting reports, report-uri can point at a simple endpoint that appends to a log file. It does not need to be sophisticated. It needs to exist before you enforce.
What a CSP is and is not
It does not stop code being injected. It limits what injected code can do once it is there.
That distinction changes how you describe it to a client. A CSP is not a shield that stops attacks; it is a set of rules the browser enforces about where content may come from, so that a successful injection has nowhere to send data and nothing external to load. It converts a potential breach into a blocked request. That is genuinely valuable and it is a narrower claim than most security pages make.
So it belongs behind the things that stop injection in the first place: current plugins, a PHP branch that still receives fixes, and not running something the directory removed years ago. A CSP on a site with an unpatched vulnerable plugin is a seatbelt in a car with no brakes.
It is also the most likely header on the list to be silently dropped by a migration or a CDN rule, the same way the simpler security headers vanish when a site moves hosts. Re-check after every infrastructure change.
Protuno's free audit reports whether a CSP is present and reads the policy from the domain alone, which is how the header above was captured. Straight with you as on every post here: Rook, the Security agent that would re-check after each deploy, is built and named but not live yet.
One last note on wildcards, because they undo the work quietly. A directive like script-src https://*.someplatform.io trusts every subdomain that platform will ever create, including ones created by other customers. Where a vendor documents specific hostnames, list those. Where they insist on a wildcard, note it as an accepted risk rather than assuming it is equivalent to a named host. Our own policy has three wildcards in it for exactly that reason, and they are the part I would tighten first if the vendor published a narrower list.
Add the four low-risk directives to one client site this week. Ten minutes, real protection, and nothing to test beyond loading the site afterwards.
What the re-read changes about how we describe our own policy
Finding a fourth wildcard while re-checking this post is not, on its own, worth an incident. It is worth a correction to how this policy gets described the next time somebody asks about it, which is the actual discipline this post is trying to model. "Three accepted-risk wildcards" was accurate the day it was written and stopped being accurate the day a new integration needed avatar images from Gravatar's own CDN. Neither fact makes the original number a mistake. Both facts together make re-reading the header, rather than quoting a number from memory or from a post written months earlier, the only reliable way to describe the policy's current state to a client or in an internal audit.
The same applies to the inline-style measurement. This post's original table describes a stock install specifically because that is the number worth knowing when deciding whether a strict CSP is feasible on WordPress at all, in principle. It is not the number worth quoting when scoping the actual work for a specific client's actual page-builder-heavy site, where the real count can run thirty times higher. Both numbers are true, they answer different questions, and conflating them is how a CSP rollout gets scoped as a ten-minute header change when the real style-src work, on a site built the way most agency client sites actually are, is closer to an architecture decision than a configuration one.
Comments