Content Security Policy: how CSP works and why it's so hard to deploy
Cross-site scripting has been near the top of every web vulnerability list for two decades, and the reason it survives is structural. A browser has no way, on its own, to tell a script the developer wrote apart from a script an attacker injected into the page. Both arrive as text inside the same document, both run with the full authority of the origin. Content Security Policy is the standard that tries to give the browser that missing ability: a server-declared rule that says which scripts are allowed to execute and which sources are allowed to load, so that an injected <script> simply never runs even if the injection itself succeeds.
That is the promise. The reality is messier. CSP has shipped in browsers since 2011, it has a mature Level 3 specification, and a large fraction of the policies actually deployed on the web in a serious 2016 measurement turned out to be trivially bypassable. The gap between “CSP exists” and “CSP protects this site” is where most of the difficulty lives, and that gap is what this post is about.
The sections below walk through the directive and source-list model, the three ways to authorise a script (allowlist, nonce, hash) and why the first one mostly failed, the strict-dynamic keyword that makes nonces practical, report-only mode and the violation reporting pipeline, the specific bypass classes that Google’s research catalogued, and the operational reasons retrofitting a strict policy onto a real codebase is so much harder than the spec makes it look. The framing throughout is defensive: how the mechanism works and where it breaks, not how to weaponise the breakage.
What CSP actually is
CSP is an HTTP response header. The server sends Content-Security-Policy: followed by a string of directives separated by semicolons, and a conforming browser enforces every directive for the lifetime of that document. There is also a <meta http-equiv="Content-Security-Policy"> form for cases where you cannot set headers, though it cannot express a few directives (notably frame-ancestors and the reporting directives) and is generally the weaker option.
The idea predates the standard by years. Robert Hansen sketched a “Content Restrictions” proposal in 2004, the concept was discussed publicly by Mozilla’s Gervase Markham and others around 2007, and Brandon Sterne and Sid Stamm built a prototype that landed in Firefox 4 in March 2011 behind the experimental X-Content-Security-Policy header. Chrome followed in August 2011 with X-WebKit-CSP. The W3C published the CSP 1.0 working draft in November 2011, CSP Level 2 became a Recommendation in 2016, and CSP Level 3 has been the working draft that browsers track ever since. The vendor-prefixed headers are long gone; today everything uses the unprefixed Content-Security-Policy name.
A policy is a list of directives, and each directive is a name followed by a list of source expressions. The canonical shape looks like this:
*A single CSP header. Each directive names a resource type; its source list decides what may load or execute for that type.*Directives fall into a few families. Fetch directives control where a given resource type may load from: script-src, style-src, img-src, font-src, connect-src, media-src, object-src, frame-src, worker-src, manifest-src and more. default-src is the fallback for any fetch directive you do not name explicitly, which is both convenient and a trap, because forgetting that an unlisted directive inherits default-src is a common source of surprise. Document directives such as base-uri and sandbox govern properties of the document itself. Navigation directives, form-action and frame-ancestors, control where forms may submit and which sites may frame the page. The reporting directives, report-uri and report-to, decide where violation reports go.
CSP Level 3 also split script-src and style-src into finer-grained pieces: script-src-elem and script-src-attr separate <script> elements from inline event-handler attributes, and style-src-elem and style-src-attr do the same for stylesheets versus inline style attributes. If you do not set them they fall back to script-src / style-src, which fall back to default-src. The cascade matters because a policy author reasoning only about <script> tags can forget that the same directive is silently governing onclick= handlers too.
The single most important point about the whole model: CSP is defence in depth, not a primary control. It does not stop the injection. If your template concatenates untrusted input into HTML, the attacker’s markup still lands in the DOM. CSP’s job is to make sure that when the browser reaches the injected <script>, it refuses to run it. The injection succeeds; the payload does not execute. Get the policy wrong and you have spent real engineering effort on a header that blocks nothing.
The three ways to trust a script
Inline script is the heart of the problem. The classic XSS payload is an inline <script> or an inline event handler, and a browser running a default-free policy treats every inline script the same way it treats the attacker’s. So the first thing a meaningful script-src does is refuse inline execution unless you opt back in. There are three mechanisms for that opt-in, and the history of CSP is largely the story of the web moving from the first to the other two.
The oldest mechanism is the host allowlist. You list the origins you trust: script-src 'self' https://cdn.example.com https://www.google-analytics.com. The browser allows scripts whose URL matches an entry and blocks everything else. This is intuitive and it is what almost everyone reached for first. It is also, as we will see, the mechanism that failed in practice.
The second is the cryptographic nonce. The server generates a fresh random value for every response, puts it in the header as 'nonce-<value>', and stamps the same value as a nonce attribute on each inline <script> it intends to allow. The browser runs only the scripts whose nonce attribute matches the header. An injected <script> has no way to carry the right nonce because it does not exist until the server generates it, per response, after the attacker’s payload was already chosen. The spec requires the nonce to be at least 128 bits of cryptographically secure randomness, base64-encoded, precisely so it cannot be guessed within the lifetime of one response.
The third mechanism is the hash. Instead of a per-response token you compute the SHA-256, SHA-384 or SHA-512 digest of the exact bytes of an inline script and put 'sha256-<base64-digest>' in the source list. The browser hashes each inline script it encounters and runs it only if the digest is in the list. Hashes need no per-request server logic, which makes them the natural fit for fully static, cacheable pages where a nonce cannot work. The cost is that any change to the script, even a whitespace edit, changes the hash and requires updating the header.
These are not mutually exclusive. A real strict policy usually combines a nonce (for the page’s own inline bootstrap scripts) with strict-dynamic (covered below) and sometimes a handful of hashes for third-party snippets you cannot nonce. The keyword 'unsafe-inline' does the opposite of all this: it tells the browser to allow every inline script unconditionally, which reopens exactly the hole CSP was meant to close. Its sibling 'unsafe-eval' re-enables eval(), setTimeout with a string argument, and the Function constructor. Both keywords exist for compatibility and both defeat the purpose if left in a script-src.
Why host allowlists failed
For years the recommended way to write a CSP was the host allowlist, and in 2016 a group of Google researchers measured how well that recommendation had held up. The paper, “CSP Is Dead, Long Live CSP! On the Insecurity of Whitelists and the Future of Content Security Policy” by Lukas Weichselbaum, Michele Spagnuolo, Sebastian Lekies and Artur Janc, presented at ACM CCS 2016, used the Google search index to extract policies at a scale no one had attempted before. They looked across roughly 106 billion pages, found about 3.9 billion carrying a CSP, and distilled those down to 26,011 unique policies.
The headline number is brutal. At least 94.72% of the policies that tried to mitigate XSS were ineffective at it. The reason was almost always the allowlist itself. When the researchers examined the fifteen domains most commonly allowlisted for scripts, fourteen of them hosted at least one endpoint that let an attacker turn an allowed origin into arbitrary script execution. Once any such endpoint sits on an allowlisted host, the allowlist is worthless: the attacker injects a <script src> pointing at that endpoint, the URL matches the policy, and the browser runs whatever the endpoint returns. Across the corpus, 75.81% of distinct policies used script allowlists that could be bypassed this way.
The usual culprit is JSONP. A JSONP endpoint takes a callback name in a query parameter and echoes it back wrapped around some data, which means https://trusted-cdn.example/jsonp?callback=ATTACKER_CODE returns a response that begins with attacker-controlled JavaScript. If trusted-cdn.example is on your script-src, that single endpoint hands an attacker full script execution inside your origin. The second culprit is a hosted copy of a library with a client-side templating engine, AngularJS being the famous case, where loading the framework plus an attacker-controlled template expression yields code execution through the framework’s own evaluation, again from a perfectly allowlisted host. Neither of these is exotic; they were sitting on the CDNs that everyone allowlisted.
The structural lesson is that a host allowlist trusts an entire origin’s worth of behaviour, and modern CDNs host enough varied functionality that “this whole origin is safe to run as script” is almost never true. You cannot audit every endpoint on ajax.googleapis.com or a large CDN, and you do not control what they add next month. The allowlist model asks a question the web cannot answer.
A second, quieter failure mode runs alongside the JSONP problem: open redirects on an allowlisted host. CSP source matching follows redirects, so if a trusted origin exposes a redirect endpoint that an attacker can point anywhere, that endpoint can bounce a script load off the allowlisted host and onto an attacker-controlled one while still satisfying the policy. Wildcard entries make all of this worse. A script-src *.example.com trusts every subdomain, including ones that get spun up by other teams, abandoned, or pointed at third-party SaaS that the security team never reviews. The allowlist was supposed to be a small set of audited origins; in practice it grows into a list nobody owns, and any single weak entry in it is enough to undo the whole directive.
strict-dynamic and the nonce-first model
The same paper proposed the fix, and the fix is now the mainstream recommendation. Drop the allowlist entirely. Authorise scripts with a nonce, and add the keyword 'strict-dynamic'.
The problem strict-dynamic solves is propagation. Real applications load scripts that load other scripts. Your nonced bootstrap script injects a tag for a third-party library, that library injects its own dependencies, and none of those dynamically created tags carry your nonce because your server never saw them. Under a plain nonce policy they would all be blocked. strict-dynamic changes the rule: a script that was itself trusted (because it had a valid nonce or hash) propagates that trust to scripts it creates programmatically through the DOM, for example via document.createElement('script') and appendChild. Trust flows down the chain of scripts that legitimately created each other, while markup-injected <script> tags, the kind an attacker produces, get no nonce and no trust.
Crucially, when strict-dynamic is present the browser ignores host-based and scheme-based expressions in that directive entirely. So script-src 'nonce-r4nd0m' 'strict-dynamic' https://cdn.example.com does not trust cdn.example.com at all in a modern browser; the host entry is there only as a fallback for ancient browsers that do not understand strict-dynamic. This is what lets a strict policy stay short and stop chasing CDN allowlists. The recommended baseline that came out of this work is:
Content-Security-Policy: script-src 'nonce-{RANDOM}' 'strict-dynamic' https: 'unsafe-inline'; object-src 'none'; base-uri 'none';Every token earns its place. The nonce authorises your real inline scripts. 'strict-dynamic' lets them load their dependencies. https: and 'unsafe-inline' are pure backward-compatibility padding: a browser old enough to ignore both strict-dynamic and nonces falls back to the loose https:/unsafe-inline behaviour, while every browser that understands strict-dynamic ignores those two tokens completely. So the policy degrades to “weak but not broken” on old engines and is strict everywhere modern.
object-src 'none' matters more than it looks. Plugin content, Flash and other <object>/<embed> payloads, has historically been a script-execution vector that bypasses script-src entirely, so a strict policy kills it outright. base-uri 'none' closes a subtler hole. If an attacker can inject a <base> tag, they can rewrite the origin against which your relative script URLs resolve, redirecting <script src="/app.js"> to their own server. Without base-uri restriction, that trick can defeat an otherwise careful nonce policy. The hash-based variant for static pages swaps the nonce for 'sha256-...' and is otherwise identical.
The same family of controls extends to the DOM-XSS problem that CSP’s script rules do not touch. require-trusted-types-for 'script', together with the trusted-types directive, makes the browser reject plain strings passed to dangerous DOM sinks like the innerHTML setter unless they have been minted by a registered Trusted Types policy. That narrows the DOM-XSS attack surface down to the few code paths that create those policies, which you can then review by hand. Trusted Types is the natural companion to a nonce-based CSP: the CSP stops injected <script> tags, Trusted Types stops the injection from reaching innerHTML and friends in the first place.
Report-only mode and the violation pipeline
You almost never deploy a strict CSP straight to enforcement. The path that works is to ship it in observation mode first, watch what it would have blocked, fix the legitimate breakage, and only then flip to enforcement. The mechanism for that is a second header, Content-Security-Policy-Report-Only. A policy sent under that name is evaluated exactly like a real one and generates a violation report for everything it would block, but it blocks nothing. The page keeps working while you collect data.
You can send both headers at once. A common production setup runs an enforced policy that is known-safe plus a stricter report-only policy you are trialling, so you tighten in the report-only channel and promote rules into the enforced channel once the report stream is clean.
When a violation happens, the browser assembles a report. Under the older report-uri directive the browser POSTs a JSON object whose csp-report member carries fields including document-uri (where the violation happened), blocked-uri (what was blocked), violated-directive and effective-directive (which rule fired), original-policy (the full policy string), disposition (enforce or report), and status-code. The newer report-to directive routes through the Reporting API instead, naming an endpoint group defined in a separate Reporting-Endpoints response header, and uses slightly different camelCase field names like blockedURL, documentURL, effectiveDirective, originalPolicy, lineNumber, sourceFile and sample.
The migration between the two is unfinished. report-uri is formally deprecated in favour of report-to, browsers that support report-to ignore report-uri when both are present, but report-to and the underlying Reporting API took years to reach broad support and were Chromium-first for a long stretch. The standing advice is to send both directives so you cover browsers on either side of the transition, which is itself a small example of the compatibility tax that runs through all of CSP.
Two cautions about the report stream. It is noisy: browser extensions, injected antivirus scripts, and ISP content tampering all trip violations that have nothing to do with your code, so the first week of a report-only rollout is mostly triage. And the blocked-uri field is deliberately coarse for cross-origin cases, often reduced to just the origin or even the literal string inline, to avoid leaking information about the injected content back through the report channel. That privacy hedge means the report tells you a rule fired but not always exactly what tripped it.
There is a deployment subtlety worth stating plainly here. The reports describe what the browser would do under the policy you sent, not what an attacker is doing. A clean report stream means your legitimate code is compatible with the policy; it does not mean the site is under attack or free of one. Teams sometimes read a quiet report endpoint as “CSP is protecting us” when all it confirms is “CSP is not breaking us,” which are different claims. The volume also scales with traffic, so a popular site running report-only can generate report floods that need sampling and aggregation before they are usable, and the report-sample keyword, which attaches the first part of the offending script to the report, has to be weighed against the small information-leak it introduces into your own logs. The point of the report-only phase is to turn an abstract policy into a concrete list of code paths to fix, and that list is only as good as your ability to read past the noise around it.
Why retrofitting CSP onto a real site is painful
A greenfield app can adopt a strict CSP cheaply because you write nonce-aware templates from day one. Retrofitting one onto a site that already exists is where teams stall, and the reasons are concrete rather than philosophical.
Start with inline everything. Old codebases are full of inline <script> blocks, inline event handlers (onclick, onload), and javascript: URLs, and a strict script-src blocks all of them. Each one has to be moved to an external file or given a nonce, and inline event handlers in particular are governed by script-src-attr, which 'strict-dynamic' does not help with at all. There is 'unsafe-hashes' to allow specific hashed event handlers, but it is a narrow tool and a slippery slope. A large template tree can hold thousands of these, many generated by frameworks or third-party widgets you do not control.
Then there is the caching conflict, which is the sharpest one. A nonce must be unique per response, which means the HTML carrying it cannot be cached, because a cached page would serve the same nonce to many users and a stale nonce is no nonce at all. “Number used once” stops being true the moment a cache replays it. A 2023 study, “The Nonce-nce of Web Security” by Matteo Golinelli and colleagues, presented at ESORICS 2023 workshops, measured this directly: of 2,271 sites deploying a nonce-based policy, 598 reused the same nonce value across more than one response, roughly 26%. Some of that came from server code that generated the nonce once instead of per-request; some came from web caches replaying a nonce that was fine when first minted. Either way, a reused nonce is a guessable nonce for the attacker who can fetch the page themselves, and the CSP’s core protection evaporates. This is why frameworks built around aggressive HTML caching, including modern static and edge-rendered stacks, keep filing issues about nonce-based CSP being fundamentally incompatible with their cache layer; the hash-based variant exists precisely for the static case, at the cost of re-hashing on every script change.
Third-party scripts are the third wall. Analytics, tag managers, ad scripts, A/B testing, chat widgets, all of them inject script, and many were written without any awareness of CSP. strict-dynamic helps with the well-behaved ones that load through document.createElement, but plenty still use techniques that the propagation rule does not cover, or they inject inline handlers, or they expect to be allowlisted by host. A tag manager that lets non-engineers paste arbitrary snippets into the page is, by design, a hole in any script policy. You often end up negotiating with vendors or pinning specific hashes for snippets that change on the vendor’s schedule, not yours.
None of these is a spec defect. They are the friction of mapping a strict allowlist onto code that was never written with one in mind.
Two more traps round it out. The default-src fallback means a directive you forgot to write is not absent, it inherits default-src, so a policy that looks locked down can be wide open for connect-src or frame-src because the author reasoned only about scripts. And script-src does nothing for DOM-based XSS, where the injection never creates a new script tag but instead feeds attacker data into an existing sink like element.innerHTML. That class needs Trusted Types, which is its own retrofit project with its own breakage surface. The honest summary is that a strict CSP is not a header you add; it is a property of how the whole front end is built, and retrofitting it means changing the build, not the config.
CSP also sits next to several other controls that solve adjacent problems, and the boundaries matter. frame-ancestors is the modern replacement for X-Frame-Options and handles clickjacking, a different attack from script injection. Subresource Integrity pins the exact hash of a specific third-party file so a compromised CDN cannot swap it, which complements but does not replace a script policy; the Subresource Integrity post covers that supply-chain angle, and the Polyfill.io supply-chain attack shows what happens when neither control is in place and a trusted CDN goes rogue. CSP’s connect-src limits where script can exfiltrate to, which interacts with the same-origin questions covered in the CORS and same-origin policy post. And cookie hardening, the HttpOnly, Secure, SameSite and __Host- story, reduces what a successful XSS can steal even when CSP fails. None of these is a substitute for the others; they are layers, and CSP is the layer that stops injected script from running.
Where CSP stands now
A decade after the Google study, the verdict has held up and the recommendation it produced is the consensus. Host allowlists for scripts are understood to be a dead end, the nonce-plus-strict-dynamic pattern is what the major guides teach, and Trusted Types extends the same nonce-first philosophy to the DOM-XSS sinks that script-src never covered. The mechanism works. A correctly built strict CSP genuinely turns most reflected and stored XSS from “attacker runs code in your origin” into “attacker injects markup that does nothing,” which is the outcome the whole standard was designed to produce.
What has not changed is the deployment gap. The 2016 measurement found most real policies broken; the 2023 nonce-reuse measurement found a quarter of nonce deployments undermining themselves through caching and server bugs. The pattern is consistent. The hard part of CSP has never been the specification, which is clear and well-supported, but the collision between a strict per-response security token and the way large sites are actually built: cached aggressively, stitched together from third-party tags, carrying years of inline scripts no one wants to touch. The header is a one-liner. The work behind a header that actually protects anything is a front-end rebuild, and that asymmetry is exactly why so many sites ship a CSP that blocks nothing and call it done. The most useful thing a team can do before writing the policy is run it in report-only for a few weeks and read what the violation stream says about their own code, because the report stream tends to describe a site that nobody fully remembers building.
Sources & further reading
- Weichselbaum, Spagnuolo, Lekies, Janc (2016), CSP Is Dead, Long Live CSP! On the Insecurity of Whitelists and the Future of Content Security Policy — the ACM CCS 2016 measurement study of 26,011 policies that showed 94.72% were ineffective and proposed strict-dynamic.
- Golinelli et al. (2023), The Nonce-nce of Web Security: an Investigation of CSP Nonces Reuse — ESORICS 2023 study finding 598 of 2,271 nonce-based sites reuse nonces across responses.
- MDN (2026), Content-Security-Policy header reference — the full directive list, source-list keywords, and report-uri/report-to details.
- web.dev / Google (2024), Mitigate XSS with a strict Content Security Policy — the canonical nonce and hash policy recipes plus the backward-compatibility fallbacks.
- W3C (2016, working draft), Content Security Policy Level 3 — the specification browsers track, including nonce entropy requirements and strict-dynamic semantics.
- MDN (2026), require-trusted-types-for directive — how Trusted Types guards DOM XSS sinks alongside a strict CSP.
- OWASP (2026), Content Security Policy Cheat Sheet — practical hardening guidance and the directive-by-directive defensive checklist.
- MDN (2026), CSP report-to directive — the Reporting API integration and the migration away from report-uri.
- Mozilla Security Blog (2013), Content Security Policy 1.0 Lands in Firefox — early implementation history and the move off the X-Content-Security-Policy prefix.
- Wikipedia (2026), Content Security Policy — consolidated timeline from the 2004 Content Restrictions proposal through CSP Level 3.
- content-security-policy.com (2024), strict-dynamic explained — a focused reference on how trust propagation works under strict-dynamic.
Further reading
CORS, the same-origin policy, and the long history of cross-origin trust
Traces the same-origin policy from Netscape 1995 to RFC 6454, then how CORS relaxes it through preflights and Access-Control headers, the misconfigurations that break it, and where the model stands in 2026.
·21 min readCookie security: HttpOnly, Secure, SameSite, and the __Host- prefix
A primary-source reference for the cookie security attributes: what HttpOnly, Secure, SameSite, Domain, and Path each enforce, why the __Host-/__Secure- prefixes exist, and the gaps each one leaves behind.
·24 min readSubresource Integrity and the supply-chain risk of third-party scripts
Traces how the integrity attribute verifies a third-party script against a cryptographic hash, what a compromised CDN it stops, the dynamic-resource gap it cannot close, and why adoption stayed in single digits.
·20 min read