Here is a policy we see constantly. It is on the site, it shows up green in whatever header-checking tool the client ran, and it restricts absolutely nothing that matters.
Content-Security-Policy: frame-ancestors 'none'; base-uri 'self';
object-src 'none'; form-action 'self'Read it again. There is no script-src. There is no default-src for script-src to fall back to. Script loading is completely unrestricted. The page is protected against being framed and against having its form targets rewritten, which is worth having, but if an injection bug ever appears in this application the policy will not slow it down for a millisecond.
This is the most common Content-Security-Policy failure mode. Not a missing header — a header that looks like a policy and behaves like a decoration.
What the header actually does
Content-Security-Policy is a browser-enforced allowlist declaring where a page may load resources from. The browser reads the header, and refuses anything the policy does not permit. It is defined in the W3C's Content Security Policy Level 3 specification.
The critical thing to understand is that it is a containment control, not a fix. It does not stop an injection bug from existing. It stops that bug from doing anything useful once it does. That distinction matters when you are deciding how urgently to deploy one: it is not an emergency until the day it suddenly is.
Why host allowlists stopped working
The original approach was to list the domains you trust:
script-src 'self' https://cdn.example.com https://www.google-analytics.comThis looks reasonable and fails in practice for two reasons.
First, big CDNs host everything. If your allowlist includes a general-purpose CDN, an attacker who can inject a <script src> tag can very often find something on that same CDN — an old AngularJS build, a JSONP endpoint — that lets them execute arbitrary code. Your allowlist said yes to the domain, not to the file.
Second, allowlists rot. Someone adds a tag manager under deadline, the policy gets widened to accommodate it, and nobody ever narrows it again. Google's own research into deployed policies found the large majority of real-world allowlist policies were bypassable.
The pattern that replaced it: nonce plus strict-dynamic
A nonce is a random value generated fresh for every single response. The server puts it in the header and on each script tag it intends to allow. The browser runs only the scripts carrying the matching value.
Content-Security-Policy: script-src 'nonce-e10f01f2b15e404bb2b6f55d59fdb2f5' 'strict-dynamic'<script nonce="e10f01f2b15e404bb2b6f55d59fdb2f5" src="/app.js"></script>An attacker injecting a script tag cannot guess the nonce, so their tag does not run. Two rules govern this and both are absolute: the value must be cryptographically random, and it must never be reused across responses. A nonce baked into a cached static page is not a nonce, it is a password everybody knows.
'strict-dynamic' handles the awkward part. Modern applications load scripts from scripts — a bundler loads a chunk, which loads another chunk. Those child scripts have no nonce. 'strict-dynamic' says: any script loaded by an already-trusted script inherits that trust. Without it you would be adding nonces to code you do not control.
The keywords that look like mistakes and are not
A correctly built strict policy contains lines that will make a reviewer flinch:
script-src 'self' 'nonce-{RANDOM}' 'strict-dynamic'
'wasm-unsafe-eval' https: 'unsafe-inline';'unsafe-inline' and https: sitting in a script-src is normally a red flag. Here they are inert. A browser that understands 'strict-dynamic' ignores both host allowlists and 'unsafe-inline' whenever a nonce is present. They exist purely so that an older browser, which does not understand 'strict-dynamic', still gets a working page instead of a blank one. This is the documented fallback pattern in Google's strict CSP guidance.
'wasm-unsafe-eval' is narrower than it sounds. It permits WebAssembly compilation without permitting eval() on JavaScript. If part of your media pipeline needs WASM, this is the correct keyword to reach for rather than the much broader 'unsafe-eval'.
Deploy in report-only mode first. Always.
There is a second header, Content-Security-Policy-Report-Only, which uses identical syntax and enforces nothing. The browser evaluates the policy, logs what it would have blocked, and lets the page work normally.
Ship the report-only version first. Leave it up long enough to cover the pages that only get visited occasionally. Then switch.
One practical warning about the switch: once enforcing, remove the report-only header. If both are present the browser honours both independently, and you will spend an afternoon debugging violations from a policy you thought you had replaced.
The things that break, in the order they break
From experience, a first strict policy usually trips over the same handful of things.
| What breaks | Why | Usual fix |
|---|---|---|
| Video players | Blob URLs for media segments and worker threads | media-src blob: and worker-src 'self' blob: |
| PDF viewers | Often compile WebAssembly and render to a blob | 'wasm-unsafe-eval', plus blob in object-src or frame-src |
| Analytics and tag managers | Inject further scripts at runtime | 'strict-dynamic' normally covers this |
| Framework inline styles | Styles emitted at runtime cannot carry a nonce | style-src 'unsafe-inline' — see below |
| Fonts and data URIs | data: is not permitted by default | font-src 'self' data: |
Test the video and document paths specifically before enforcing. They are the ones that fail silently in a way nobody notices until a customer complains.
The style-src compromise nobody likes
Most component frameworks emit inline styles at runtime, and those styles cannot carry a nonce without a build-level change. So a great many real policies end up here:
style-src 'self' 'unsafe-inline';This one is genuinely permissive, unlike the script-src keywords above. It is worth being straight about that rather than pretending otherwise. The residual exposure is style-based injection — defacement, or clickjacking-adjacent tricks — which cannot execute script under this policy. Most teams accept it. The right move is to record it as an accepted residual with a reason, not to leave it undocumented and hope nobody asks.
Add a reporting endpoint
A policy with no report-uri or report-to directive is enforcing blind. You will find out it broke something when a user tells you, which is the worst available feedback loop.
With reporting configured, the browser posts a JSON report on every violation. That turns your next policy change from a spot check into a measurement. It is the single highest-value addition to a policy that is otherwise already correct.
A complete working policy
Pulling it together. Adjust the media hosts to your own; everything else is a reasonable default.
Content-Security-Policy:
default-src 'self';
script-src 'self' 'nonce-{RANDOM}' 'strict-dynamic'
'wasm-unsafe-eval' https: 'unsafe-inline';
style-src 'self' 'unsafe-inline';
img-src 'self' data: blob: https://cdn.example.com;
font-src 'self' data:;
connect-src 'self' https://cdn.example.com;
media-src 'self' blob: https://cdn.example.com;
worker-src 'self' blob:;
frame-ancestors 'none';
base-uri 'self';
object-src 'none';
form-action 'self';
report-to csp-endpointNote that frame-ancestors, base-uri, form-action and object-src are listed explicitly. They are not fetch directives, so default-src does not cover them. Leaving them out is the second most common mistake after leaving out script-src entirely.
Security & Configuration Audit
Security audit services covering TLS, HTTP security headers, cookies, Content-Security-Policy, HTTP methods, exposed files and error handling, measured against current practice. Free consultation.
See how we run it →