Content Security Policy (CSP)
CSP is a response header that tells the browser “only execute scripts/styles/etc. from these sources.” Even if XSS injects a <script>, the browser refuses to execute it if the source isn’t allowed. The senior knowledge: how directives work, nonce vs hash vs 'self', the killer footgun ('unsafe-inline' + 'unsafe-eval'), how to roll out via report-only mode, and how it interacts with Trusted Types for an even stronger XSS posture.
What does CSP look like, basically?
Response header listing allowed sources per resource type:
Content-Security-Policy:
default-src 'self';
script-src 'self' https://cdn.example.com 'nonce-abc123';
style-src 'self' 'unsafe-inline';
img-src 'self' data: https:;
connect-src 'self' https://api.example.com;
frame-ancestors 'none';Effect:
- Scripts may load from
'self'(same origin),cdn.example.com, or inline scripts withnonce="abc123". - Styles may load from
'self'or inline. - Images may load from
'self', data URLs, or any HTTPS origin. - API calls (XHR, fetch, WebSocket) only to
'self'orapi.example.com. - This page can’t be framed (anti-clickjacking).
Any other source → browser blocks the request (or refuses to execute, for inline).
Common directives.
| Directive | What it controls |
|---|---|
default-src |
fallback for any directive not specified |
script-src |
JS sources + inline (<script>, event handlers, eval) |
style-src |
CSS sources + inline (<style>, style= attributes) |
img-src |
image sources |
font-src |
font sources |
connect-src |
fetch, XHR, WebSocket, EventSource targets |
frame-src |
iframe sources |
frame-ancestors |
who can frame me (anti-clickjacking) |
base-uri |
what <base> element can point to |
form-action |
where forms can submit |
object-src 'none' |
block <object>/<embed> |
media-src |
audio/video |
worker-src |
Web Worker, Service Worker, Shared Worker |
manifest-src |
PWA manifest |
child-src |
workers + frames (legacy combined) |
default-src 'self'; object-src 'none'; base-uri 'self' is a solid minimal baseline. Then add per-resource as needed.
Source list keywords — 'self', 'none', 'unsafe-inline', 'unsafe-eval'.
'self'— same origin (scheme + host + port).'none'— nothing allowed.'unsafe-inline'— allow inline<script>,style=, event handlers. Defeats most of CSP for that resource.'unsafe-eval'— alloweval,Function(). Some old libs need it.data:—data:URLs (for images/fonts).https:— any HTTPS origin.'wasm-unsafe-eval'— allow WebAssembly compilation (modern WASM apps).
The senior tell: 'unsafe-inline' in script-src makes CSP cosmetic. XSS still works. The whole point of CSP for XSS defense is blocking inline scripts — 'unsafe-inline' undoes it.
nonce and hash — the inline-script escape hatches.
When you genuinely need inline scripts (analytics snippets, hydration data, SSR’d state), use nonce or hash instead of 'unsafe-inline'.
Nonce: server generates a random value per request, includes in CSP header AND in the nonce attribute of each allowed inline script:
Content-Security-Policy: script-src 'nonce-abc123' 'self'<script nonce="abc123">window.__INITIAL_DATA__ = {...};</script>The browser executes only <script> tags with the matching nonce. Attackers don’t know the nonce; injected scripts have no nonce; blocked.
Hash: include a SHA-256/384/512 hash of the script’s exact content. The browser computes the hash of each inline script; matches → allowed.
Content-Security-Policy: script-src 'sha256-Ed7K...x4='Use nonce for dynamic inline content (per-request). Use hash for static, build-time inline scripts.
strict-dynamic — modern recommended.
'strict-dynamic' tells the browser: “trust this nonce/hash’d script, and any scripts it loads dynamically.” Lets a nonce’d loader script include other scripts without listing each.
Content-Security-Policy: script-src 'nonce-abc123' 'strict-dynamic' 'unsafe-inline' https:(The 'unsafe-inline' and https: are fallbacks for browsers that don’t support 'strict-dynamic'; modern browsers ignore them when 'strict-dynamic' is present.)
This is the recommended modern pattern for SPAs that load chunks dynamically (Next.js, Vite). Without 'strict-dynamic', every chunk URL has to be in the source list.
How do you roll out CSP without breaking things?
Content-Security-Policy-Report-Only — the browser reports violations but doesn’t block:
Content-Security-Policy-Report-Only: default-src 'self'; report-uri /csp-reportThe browser POSTs JSON violation reports to /csp-report. You collect, analyze, iterate the policy. When violations are zero (or only attacks), flip to enforcing Content-Security-Policy.
This is mandatory for any real app — CSP affects every page, and one wrong directive can block half your assets. Roll out via report-only over weeks; switch to enforcement only when reports are clean.
report-uri vs report-to.
report-uri— older, simple POST endpoint. Widely supported.report-to— newer, uses the Reporting API (Reporting-Endpointsheader). More flexible. Less universal.
In practice, set both:
Reporting-Endpoints: csp-endpoint="/csp-report"
Content-Security-Policy:
default-src 'self';
report-uri /csp-report;
report-to csp-endpoint;Use a CSP reporting service (Sentry, Report URI, Datadog) to aggregate — raw reports are noisy.
CSP for SPAs — what’s tricky?
- Inline scripts for hydration data (
__NEXT_DATA__,__NUXT__) need nonces/hashes per page. Frameworks auto-emit these. - Dynamic chunks loaded by the bundler — list with
'strict-dynamic'or by exact URLs. - HMR in dev uses inline scripts + eval; relax CSP in dev, tighten in prod.
- Third-party scripts (analytics, ads) — list each origin in
script-src. Bigger third-party surface = bigger CSP. - Style-in-JS / Emotion / styled-components — emit inline
<style>tags. Use'unsafe-inline'instyle-srcOR (better) hash-based or nonce-based styles. Some libraries support hash; check.
For a modern Next.js SPA:
Content-Security-Policy:
default-src 'self';
script-src 'nonce-{{NONCE}}' 'strict-dynamic';
style-src 'self' 'nonce-{{NONCE}}';
img-src 'self' data: https:;
font-src 'self';
connect-src 'self' https://api.example.com;
frame-ancestors 'none';
base-uri 'self';
form-action 'self';
report-to csp-endpoint;What’s Trusted Types and how does it pair with CSP?
Trusted Types blocks dangerous DOM sinks (innerHTML, script.src, etc.) from accepting raw strings — they must be wrapped in a trusted type via a “policy.”
Content-Security-Policy: require-trusted-types-for 'script'; trusted-types my-sanitizerconst policy = trustedTypes.createPolicy("my-sanitizer", {
createHTML: (input) => DOMPurify.sanitize(input),
});
el.innerHTML = policy.createHTML(userHtml); // ok
el.innerHTML = userHtml; // throws TypeErrorThis catches DOM-based XSS even when an attacker bypasses output encoding — they can’t write to innerHTML without going through a policy that sanitizes.
Chrome/Edge only currently; polyfills exist. Adoption is growing.
Common CSP mistakes.
script-src 'unsafe-inline'— defeats CSP for XSS defense.script-src *— allows any origin. Useless.- Forgetting
object-src 'none'— old Flash/<object>attacks. frame-ancestors 'none'missing — clickjacking risk.base-uri 'self'missing —<base href>can hijack relative URLs.- Not reporting — you don’t know what’s failing in real users’ browsers.
- Different policies in dev vs prod without testing — CSP issues only appear in prod.
Gotchas
<meta http-equiv="Content-Security-Policy">— fallback when you can’t set headers. Limited (some directives ignored, likeframe-ancestors). Always prefer the header.- Service worker fetches — bypass CSP for the response itself, but the cached responses still execute under CSP.
- iframe
srcdoc=content — child inherits parent CSP plus its own restrictions. - Browser extensions can inject scripts; CSP doesn’t block extensions (intentional — they’re “trusted by user”).
- CSP and Stripe / third-party widgets — these often need
unsafe-inlinefor their iframes. Read each provider’s CSP guidance. - Reporting noise — extensions, browser quirks, ad blockers all generate reports. Filter aggressively.
Related
Further reading
- MDN — CSP: https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP
- CSP Reference (directive cheat sheet): https://content-security-policy.com/
- web.dev — Strict CSP: https://web.dev/articles/strict-csp
- Trusted Types: https://web.dev/articles/trusted-types
Interview angle 5
- “What is CSP for?” - a browser-enforced allowlist of what may load and run
on the page. It is the second line after output escaping: even if an injection
lands a
<script>in the DOM, CSP stops it executing unless it comes from an allowed source. - “What silently defeats it?” -
'unsafe-inline'inscript-src. It is the quickest way to make a policy stop breaking your site, and it removes exactly the protection you deployed CSP for, because injected script is inline script.- Follow-up: “So how do you allow your own inline scripts?” - a per-response
nonceon the tags you control, or a hash for static ones.strict-dynamicis the modern pattern for a bundled app: trust the nonce’d loader and let it vouch for what it loads, rather than maintaining a host allowlist.
- Follow-up: “So how do you allow your own inline scripts?” - a per-response
- “How do you roll one out without breaking production?” -
Content-Security-Policy-Report-Onlyfirst. Collect violations for a few weeks, fix what is genuinely yours, then flip to enforcing. Deploying a strict policy straight to enforcement breaks third-party scripts nobody remembered. - “What is a reasonable baseline?” -
default-src 'self',object-src 'none'to kill plugin vectors,base-uri 'self'so an injected<base>cannot re-point every relative URL, andframe-ancestors 'none'for clickjacking. Tighten from there. - “Where do the reports go?” - an aggregating service rather than an endpoint you wrote. Raw reports are extremely noisy - browser extensions generate most of the volume - so filtering and grouping is the difference between a signal and a firehose nobody reads.