Reading a Content-Security-Policy header without crying
A typical Content-Security-Policy header looks like a UN treaty: one long line of
semicolon-separated terms, half of which look like options and half like flags.
Most engineers either copy a template from MDN once and never touch it again, or write
default-src *; script-src 'unsafe-inline' 'unsafe-eval' * and call it a day.
Both reactions are wrong. CSP done badly is worse than no CSP — it gives a false sense of defence. CSP done well stops a real chunk of XSS bugs from ever shipping data out. The gap between the two is small in practice. Here's the field guide.
What CSP actually does
A CSP header tells the browser which sources of code, styles, images, frames, etc. it is allowed to load and execute. If a directive lists a source, that source is allowed. Anything else is blocked. There's no magic. The work is in the policy, not in the browser.
The most important directive is script-src, because that's where injected JS
tries to land. Everything else is supporting cast.
The seven directives that actually matter
| Directive | What it controls | Default fallback |
|---|---|---|
default-src | Fallback for unspecified directives | none |
script-src | JS execution: tags, eval, inline | default-src |
style-src | CSS: tags, inline, attribute | default-src |
img-src | Images: tags, CSS backgrounds | default-src |
connect-src | fetch/XHR/WebSocket/EventSource | default-src |
frame-ancestors | Who can frame your page (clickjacking) | — |
base-uri | Allowed values for <base> | — |
There are more (object-src, font-src, media-src,
worker-src, form-action, upgrade-insecure-requests),
but if you nail those seven, you've covered ~95% of real attack surface.
Source expressions: the seven "shapes"
Each directive accepts a list of source expressions. There are seven shapes:
- Schemes —
https:,data:,blob:. Allows everything from that scheme. - Hosts —
cdn.example.com,*.example.com. Allows that origin. - Keywords —
'self','none','unsafe-inline','unsafe-eval','strict-dynamic','wasm-unsafe-eval'. - Nonces —
'nonce-rAnd0m=='. Allows scripts/styles tagged withnonce="rAnd0m==". - Hashes —
'sha256-base64encodedhash='. Allows scripts whose body hashes match. - Reporting endpoints —
report-uri /csp-report(deprecated) orreport-to group-name. - The wildcard —
*. Don't.
Three policies, ranked by safety
The "I just want it green" anti-policy
Content-Security-Policy: default-src *; script-src * 'unsafe-inline' 'unsafe-eval'
Allows literally everything. The header is present, so naïve scanners give it a checkmark.
But it offers zero protection — an attacker who injects a <script> tag
can fetch from anywhere and execute anything. This is XSS-as-a-service.
The "modern browsers, allow inline by hash" policy
Content-Security-Policy:
default-src 'self';
script-src 'self' 'sha256-iOO4O/cDJF6W5Sv6EElsY0ZtG2Ln3K8pQUnsvq6yw0s=';
style-src 'self' 'unsafe-inline';
img-src 'self' data:;
connect-src 'self' wss:;
frame-ancestors 'none';
base-uri 'self';
report-uri /csp-report
This is what UnveilScan actually serves. Real protection: only same-origin scripts run, plus
one inline script identified by SHA-256 hash (the SvelteKit bootstrap). XSS injecting any
other inline script gets blocked. frame-ancestors 'none' kills clickjacking.
base-uri 'self' kills <base>-based redirect tricks. The
report-uri sends violations to an endpoint we log.
The strict-dynamic policy (best of both worlds)
Content-Security-Policy:
default-src 'self';
script-src 'nonce-rAnd0mBase64==' 'strict-dynamic';
style-src 'self' 'unsafe-inline';
img-src 'self' data: https:;
base-uri 'self';
frame-ancestors 'none';
report-to csp-endpoint
A nonce is generated per request and added to every legitimate <script>
tag. 'strict-dynamic' tells the browser: "if a nonced script dynamically loads
more scripts, trust those too." This lets bundlers like webpack code-split freely while
rejecting anything an XSS payload would try to inject. The "host whitelist" (cdn.foo.com)
disappears entirely — easier to maintain, more secure.
The mistakes we see all the time
1. 'unsafe-inline' on script-src
This single keyword removes 80% of CSP's protection. It allows any inline script the page
contains, which means an attacker who injects <script>steal()</script>
anywhere in your HTML wins. Replace with nonces or hashes.
2. Wildcard hosts on script-src
script-src *.googleapis.com looks tight until someone realises Google hosts
a JSONP endpoint that returns attacker-controlled JS. *.cloudfront.net is even
worse — anyone can rent a CloudFront distribution. Pin to the exact host.
3. Missing frame-ancestors
X-Frame-Options: DENY covers older browsers, but the modern equivalent is
frame-ancestors 'none'. If you don't ship one, your login page can be framed
by an attacker site and clickjacked.
4. Forgetting base-uri
Without base-uri, an XSS payload that injects <base href="//evil.com/">
redirects every relative resource on the page through the attacker. Always pin
base-uri 'self'.
5. report-uri without an endpoint that 200s on HEAD
Some scanners (including ours) probe the report endpoint to confirm it's reachable. A handler that 405s on HEAD looks misconfigured. Make HEAD/GET return 204; only POST needs to do work.
How UnveilScan grades CSP
Our csp checker evaluates ~12 indicators inspired by Google's CSP Evaluator
methodology, including:
- Presence of
'unsafe-inline','unsafe-eval'on script-src (HIGH) - Wildcard hosts on script-src or default-src (HIGH)
- Missing
frame-ancestors+ missingX-Frame-Options(MEDIUM) - Missing
base-uri(MEDIUM) - Use of nonces/hashes vs
'unsafe-inline'(LOW informational) - Header vs
<meta>CSP (header is preferred)
Each finding includes a copy-paste fix snippet for nginx, Apache, Caddy and Cloudflare Workers. Run an Extended scan to see them.
How does your CSP grade?
Free Basic scan covers headers + CSP shape. Extended runs the full evaluator.
Run a scan