In 2016 a group of researchers at Google did something nobody had bothered to do at scale: they collected Content-Security-Policy headers from 1.6 million hostnames and checked whether the policies actually stopped anything.
The answer was no. 94.68% of policies that tried to restrict script execution could be trivially bypassed. Worse: 99.34% of hosts that bothered to deploy CSP at all were using policies that provided no meaningful protection against cross-site scripting — the exact attack CSP exists to stop. These weren’t sites that forgot to set the header. These were sites that set it, shipped it, and presumably ticked a box on a security review.
That paper is called “CSP Is Dead, Long Live CSP,” and a decade later the diagnosis still holds. CSP is the one HTTP security header where setting it and getting value from it are almost unrelated activities. You can have a long, official-looking policy that does precisely nothing. That gap is what makes it the hardest header to get right — and the reason has very little to do with syntax.
The Whitelist Was the Trap
The original mental model for CSP was a whitelist. You list the origins your scripts are allowed to come from — script-src 'self' https://apis.google.com https://cdn.example.com — and the browser blocks everything else. Intuitive. Wrong.
The problem is that a whitelisted origin is a whitelisted origin, including all the unfortunate things hosted on it. The Google researchers found that 14 of the 15 most commonly whitelisted domains hosted at least one endpoint that defeated the whole policy — a JSONP callback that reflects an attacker-controlled function name, an old AngularJS build that turns any allowed origin into an XSS gadget, an open redirect that launders the attacker’s payload through a trusted host. You whitelisted a CDN to load one library. The CDN also serves a JSONP endpoint. Now script-src https://that-cdn.com means “execute arbitrary attacker JavaScript,” and your policy is a decoration.
75.81% of distinct policies in the study had exactly this hole. The whitelist didn’t constrain the attacker; it just made the bypass one hop longer. And you cannot audit your way out of it, because you don’t control what those third parties host tomorrow.
Why the Syntax Lies to You
The thing that makes CSP genuinely treacherous is that the failure is silent and the syntax invites it.
Almost every real application has inline scripts — an analytics snippet, an event handler in the HTML, a framework’s bootstrap blob. CSP blocks inline scripts by default, because inline injection is what XSS is. So the application breaks the moment you deploy a real policy. The path of least resistance, the one every tutorial used to show, is to add 'unsafe-inline'. The page works again. The header is still there. Everyone moves on.
But 'unsafe-inline' means “allow any inline script,” which is the same as “allow injected scripts,” which is the same as having no XSS protection at all. The header is present. A scanner sees Content-Security-Policy: in the response and turns green. The policy permits the one thing it was supposed to forbid. This is the single most common way a CSP ends up in the 99.34%: not absent, but neutered by the keyword someone added to make the site stop breaking.
CSP punishes the honest configuration and rewards the one that does nothing. No other security header behaves like this. nosniff either is set or isn’t. HSTS either has a max-age or doesn’t. CSP can be fully present and fully useless, and nothing in the response tells you which.
The Escape Hatch Is Nonces, Not Lists
The fix the same researchers proposed — and then shipped across Google’s own properties — is to stop whitelisting hosts and start vouching for individual scripts. Two mechanisms do this: a nonce (a random value generated per response, attached to both the header and every legitimate <script nonce="..."> tag) or a hash of the script’s exact contents. The browser runs only scripts that carry the right nonce or match a known hash. An injected <script> has neither, so it doesn’t run — no matter which origin it claims to come from.
The clever part is 'strict-dynamic'. A nonce-based policy is great until you hit a script that itself loads more scripts (and most bundlers and tag managers do). Listing every transitively-loaded URL is hopeless. 'strict-dynamic' says: a script I already trusted via a nonce is allowed to load further scripts, and that trust propagates. You stop maintaining a URL list entirely. The policy becomes roughly script-src 'nonce-{random}' 'strict-dynamic'; and it doesn’t care what your CDN hosts tomorrow, because origins aren’t what it trusts. It trusts the nonce.
There’s a genuinely elegant bit of backwards-compatibility engineering underneath this. When a browser sees a nonce, the spec tells it to ignore 'unsafe-inline'. So you can write script-src 'nonce-r4nd0m' 'strict-dynamic' 'unsafe-inline' https: and: a modern browser obeys the nonce and strict-dynamic and ignores the rest; an older CSP2 browser ignores strict-dynamic but honors the nonce; an ancient CSP1 browser falls back to 'unsafe-inline' https:. One header, three tiers of enforcement, each as strict as the browser can manage. You don’t sniff user agents. The fallbacks are load-bearing.
So Why Doesn’t Everyone Do This
Because a strict, nonce-based CSP requires something a header cannot give you: control over how your own pages are assembled.
Nonces have to be generated fresh on every request and threaded through your templating layer to every script tag — which means your server has to render the HTML, which fights with aggressive page caching, and which falls apart the moment some script tags come from a CMS, a marketing tool, or a third-party tag manager that has never heard of your nonce. A hash-based policy means re-hashing every time a script changes, which means your build pipeline has to own the policy. Either way, getting CSP right forces you to confront your inline scripts, your legacy templates, your analytics sprawl, and whichever vendor’s <script> tag someone pasted into the <head> in 2019.
This is the real reason CSP is the hardest header. nosniff is a string you set once. HSTS is a number. CSP is an x-ray of how disciplined your front end actually is, and most front ends would rather not look. The header doesn’t ask you to add a line of config; it asks you to know where every script on your page comes from. A lot of teams discover, halfway through, that nobody does.
That’s also why it’s worth it. A site can collect a tidy row of green checkmarks — nosniff, Referrer-Policy, X-Frame-Options, all the cheap ones — and still ship the broken 'unsafe-inline' CSP that does nothing, and the scanner will call it an A. The checkmarks measure whether you set headers. Whether your CSP is real measures something else: whether you know your own application well enough to tell the browser which code is yours. That question is uncomfortable, which is exactly why the answer is worth having.