🍱 Lunchbox Hands

security

Content-Security-Policy Explained: A Practical Guide to CSP Headers

What a CSP header actually does, every major directive in plain English, nonces vs hashes vs unsafe-inline, and the gotchas (WASM, dev servers, report-only) that bite in production.

Cross-site scripting has topped web vulnerability lists for two decades, and the single most effective browser-side defense is one HTTP header most sites still don’t ship: Content-Security-Policy. CSP is an allowlist — you tell the browser exactly where scripts, styles, images, and connections may come from, and it blocks everything else, including the injected <script> an attacker just smuggled into your comments section. Here’s how it works, directive by directive, plus the real-world gotchas that break first deployments.

The mental model

CSP is a response header (or <meta http-equiv> tag) containing directives separated by semicolons:

Content-Security-Policy: default-src 'self'; script-src 'self' https://analytics.example.com; img-src 'self' data:

Each directive names a resource type and lists the origins allowed to supply it. When the page tries to load something not on the list — an inline script, an off-origin iframe, a fetch() to an attacker’s server — the browser refuses and logs a violation. The attacker’s injected markup still lands in your HTML; it just can’t execute or exfiltrate.

The directives that matter

DirectiveControlsTypical value
default-srcFallback for everything unlisted'self'
script-srcJavaScript (external, inline, eval)'self' + nonce
style-srcCSS (external and inline)'self'
img-srcImages'self' data: https:
connect-srcfetch, XHR, WebSocket targets'self' + APIs you call
font-srcWeb fonts'self'
frame-srcWhat you may embedspecific origins
frame-ancestorsWho may embed you (replaces X-Frame-Options)'none' or 'self'
base-uri<base> tag targets'self'
form-actionWhere forms may submit'self'
object-srcFlash-era plugins'none'

Two non-obvious notes: default-src does not cover frame-ancestors, base-uri, or form-action — set those explicitly or they stay wide open. And keyword values ('self', 'none', 'unsafe-inline') require the quotes; self without quotes means a host literally named “self”, and the policy silently does the wrong thing.

Inline scripts: the whole ballgame

CSP’s default behavior blocks all inline JavaScript<script> blocks, onclick= attributes, javascript: URLs. That’s the point: injected markup is inline markup. You have three ways to allow the inline code you actually mean:

  • 'unsafe-inline' — turns the protection off. An XSS payload is inline script too, so this value mostly defeats the reason you deployed CSP. Treat it as a migration crutch, not a destination.
  • Nonces — generate a random value per response, stamp it on your own tags (<script nonce="R4nd0m">), and allow script-src 'nonce-R4nd0m'. Injected scripts don’t know the nonce and die. Requires dynamic HTML.
  • Hashes — allow a specific inline block by its SHA-256 (script-src 'sha256-abc…'). Perfect for static sites: the build knows the script contents, so it can compute the hash. Change one byte and the hash — correctly — stops matching.

The modern refinement is 'strict-dynamic': trust flows from your nonce’d bootstrap script to whatever it loads, so you stop maintaining a brittle CDN allowlist entirely.

Gotchas that bite in production

  • WebAssembly needs its own keyword. Any wasm-powered feature — image codecs, ffmpeg builds, PDF renderers — fails under a strict script-src unless you add 'wasm-unsafe-eval'. Despite the scary name it only permits wasm compilation, not JS eval. We hit this ourselves shipping in-browser HEIC decoding; some libraries offer a CSP-safe build variant precisely for this.
  • Your dev server probably ignores your headers. Static-host header files (_headers on Cloudflare Pages and Netlify, vercel.json) are applied by the production edge, not by vite dev or astro dev. A policy can be badly broken and you’ll never see it locally — test on a preview deploy, or mirror the header in dev config. This bites hardest on Permissions-Policy, where an empty allowlist like microphone=() blocks the feature for your own origin and kills the permission prompt before it ever appears; we shipped exactly that bug and wrote up the whole failure mode in why your browser can’t access your microphone.
  • Third-party scripts cascade. Allowing a tag manager means allowing everything it injects — new hosts appear in violation reports weeks later. This is the strongest argument for 'strict-dynamic'.
  • data: and blob: are origins too. Canvas-generated downloads, object URLs, and inlined images need data:/blob: in the relevant directive. Add them per-directive, never to script-src.
  • upgrade-insecure-requests quietly fixes legacy http:// subresource URLs on HTTPS sites — cheaper than hunting them all down.

Roll it out without breaking the site

Deploy in report-only mode first:

Content-Security-Policy-Report-Only: default-src 'self'; report-to csp-endpoint

The browser enforces nothing but reports every would-be violation. Run it for a week or two of real traffic, fix what’s legitimate (fonts you forgot, an analytics host), then flip the header to enforcing. Going straight to enforcement on an existing site almost always takes something down — usually styles, because some framework you depend on injects inline CSS.

A sensible strict starting point for a typical static or server-rendered site:

default-src 'self';
script-src 'self';
style-src 'self' 'unsafe-inline';
img-src 'self' data:;
font-src 'self';
connect-src 'self';
object-src 'none';
base-uri 'self';
form-action 'self';
frame-ancestors 'none';
upgrade-insecure-requests

(style-src 'unsafe-inline' is the pragmatic concession most sites start with — inline styles are far less dangerous than inline scripts, though nonces work there too.)

One connect-src surprise worth knowing in advance: it also gates WebSocket connections, and a socket blocked by CSP surfaces in your onclose handler as a bare code 1006 with no reason attached — reading WebSocket close codes explains why the browser refuses to tell you more.

Build yours interactively

Hand-writing CSP strings is where the typos live — a missing quote or semicolon silently changes the policy’s meaning. The free CSP Builder lets you assemble a policy directive by directive with valid syntax guaranteed, then copy the finished header. Pair it with the HTTP Header Analyzer to confirm what your production site is actually sending — the gap between “the policy we wrote” and “the header we serve” is where most CSP surprises hide.