CSP In Depth
Nonces vs hashes, the unsafe-inline trap, Trusted Types.
CSP In Depth
Content Security Policy is a response header (or <meta> tag) that tells the browser which sources of script, style, image, and other resources it should trust for this page. A well-built CSP turns most XSS bugs into near-misses.
Analogy
Think of the guest list on a clipboard at a private gala. The doorman has strict instructions: no one gets in unless their name is on this list, and even the host can't sneak in an extra guest by slipping the doorman a note at the door. If a prankster smuggles a speech into the programme for a guest who isn't invited, the speech never gets delivered — the unlisted speaker is stopped at the threshold. CSP is that guest list for the browser: every script, stylesheet, image source, and form destination is either on the list by origin, hash, or one-time nonce, or it simply doesn't get to come in. Even if an XSS attacker slips malicious instructions into the page, if the script's source isn't on the list, the browser refuses to run it.
The directives that matter
Content-Security-Policy:
default-src 'none';
script-src 'self' 'nonce-r4nd0m';
style-src 'self';
img-src 'self' data:;
connect-src 'self';
font-src 'self';
object-src 'none';
base-uri 'none';
frame-ancestors 'none';
form-action 'self';
report-uri /csp-report;
default-src— fallback for every directive you didn't name.script-src— the big one. Where JS can come from.style-src— where CSS can come from.img-src,font-src,connect-src,media-src— the other fetches.object-src 'none'— kills<object>,<embed>,<applet>. Always'none'.base-uri 'none'— blocks<base>injection which can redirect relative URLs.frame-ancestors 'none'— clickjacking protection (replacesX-Frame-Options).form-action 'self'— where forms can post.
Sources you'll use
| Source | Meaning |
|---|---|
'self' |
Same origin (scheme, host, port). |
'none' |
Nothing matches — blocks everything. |
https://cdn.example |
Exact origin allow-listed. |
'nonce-<value>' |
Inline script/style with nonce="<value>" attribute. |
'sha256-<hash>' |
Specific inline body whose hash matches. |
'strict-dynamic' |
Trusted script can load other scripts via DOM APIs. |
Nonces vs hashes
Nonces are per-request random values. Generate one on the server, put it in the CSP header, put it in the <script nonce="…"> attribute. Each page load uses a fresh nonce. This is the recommended default for apps that need some inline script.
<script nonce="{{ nonce }}">/* framework bootstrap */</script>
Hashes pin specific inline script contents. Good for build-time-fixed snippets. Bad when the inline script changes per request.
The 'unsafe-inline' trap
'unsafe-inline' allows any inline <script>. That defeats CSP's main XSS protection — an injected payload runs just like a real script tag. Never put 'unsafe-inline' on script-src in production.
Modern browsers have a helpful rule: if your policy has both 'nonce-…' and 'unsafe-inline' on script-src, the browser ignores 'unsafe-inline'. So you can ship 'unsafe-inline' as a fallback for older browsers without weakening the policy for modern ones. Old IE/Edge legacy — mostly irrelevant now, but useful to know.
Similarly, 'unsafe-eval' allows eval, new Function, and setTimeout("string"). If your app doesn't need those (and it shouldn't), leave it out.
strict-dynamic — the pragmatic default
script-src 'nonce-r4nd0m' 'strict-dynamic';
'strict-dynamic' says: scripts that I explicitly trust (via nonce or hash) can load further scripts, but allow-listed origins and other sources are ignored. It makes CSP easier to maintain — you don't have to allow-list every CDN that a trusted script might dynamically pull in.
report-uri / report-to
Point these at an endpoint that collects CSP violation reports. In production they are your canary: if a new XSS attempt fires, you'll see the blocked request in your reports before the attacker can pivot.
For a staged rollout, use Content-Security-Policy-Report-Only first — the browser reports violations but does not block. Once your reports are clean, switch to the enforcing header.
Trusted Types
Trusted Types is a newer browser feature that refuses to let string values reach dangerous DOM sinks (innerHTML, document.write, Function, event-handler attributes). With Trusted Types enabled:
Content-Security-Policy: require-trusted-types-for 'script';
your app can only set those sinks via a typed TrustedHTML / TrustedScript / TrustedScriptURL. A plain string throws. This is the strongest DOM-XSS mitigation you can ship today.
Start in report-only, sanitize sinks via DOMPurify or a custom policy, then enforce.
Build a policy that way
- Start with
default-src 'none';— opt-in from there. - Add
script-src 'self'and remove inline script — move it to external files. - If inline is unavoidable, add
'nonce-…'per request (not'unsafe-inline'). object-src 'none',base-uri 'none',frame-ancestors 'none'.- Ship report-only first. Fix reported violations. Flip to enforcing.
- Add
require-trusted-types-for 'script'when you're ready.
A strong CSP turns "we have XSS" from a nine-alarm fire into a logged violation. Do not skip it.