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
| Directive | Controls | Typical value |
|---|---|---|
default-src | Fallback for everything unlisted | 'self' |
script-src | JavaScript (external, inline, eval) | 'self' + nonce |
style-src | CSS (external and inline) | 'self' |
img-src | Images | 'self' data: https: |
connect-src | fetch, XHR, WebSocket targets | 'self' + APIs you call |
font-src | Web fonts | 'self' |
frame-src | What you may embed | specific origins |
frame-ancestors | Who may embed you (replaces X-Frame-Options) | 'none' or 'self' |
base-uri | <base> tag targets | 'self' |
form-action | Where forms may submit | 'self' |
object-src | Flash-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 allowscript-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-srcunless you add'wasm-unsafe-eval'. Despite the scary name it only permits wasm compilation, not JSeval. 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 (
_headerson Cloudflare Pages and Netlify,vercel.json) are applied by the production edge, not byvite devorastro 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 onPermissions-Policy, where an empty allowlist likemicrophone=()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:andblob:are origins too. Canvas-generated downloads, object URLs, and inlined images needdata:/blob:in the relevant directive. Add them per-directive, never toscript-src.upgrade-insecure-requestsquietly fixes legacyhttp://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.