Content Security Policy (CSP)
TL;DR
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.
Interview Q&A
Q: What does CSP look like, basically?
A: 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).
Q: Common directives.
A:
| 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.
Q: Source list keywords — 'self', 'none', 'unsafe-inline', 'unsafe-eval'.
A:
'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.
Q: nonce and hash — the inline-script escape hatches.
A: 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.
Q: strict-dynamic — modern recommended.
A: '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.
Q: How do you roll out CSP without breaking things?
A: Content-Security-Policy-Report-Only — the browser reports violations but doesn’t block:
Content-Security-Policy-Report-Only: default-src 'self'; report-uri /csp-report
The 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.
Q: report-uri vs report-to.
A:
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.
Q: CSP for SPAs — what’s tricky?
A:
- 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;
Q: What’s Trusted Types and how does it pair with CSP?
A: 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-sanitizer
const policy = trustedTypes.createPolicy("my-sanitizer", {
createHTML: (input) => DOMPurify.sanitize(input),
});
el.innerHTML = policy.createHTML(userHtml); // ok
el.innerHTML = userHtml; // throws TypeError
This 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.
Q: Common CSP mistakes.
A:
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 / edge cases
<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.
What a senior is expected to say
- “CSP is the browser-enforced ‘what’s allowed to run here’ allowlist. Second line of defense after output escaping — even if XSS gets a
<script>in, CSP blocks execution unless it’s from an allowed source.” - “
'unsafe-inline'inscript-srcdefeats CSP for XSS defense. Use nonces for inline scripts (per-request random) or hashes (static).'strict-dynamic'is the modern pattern for SPAs.” - “Roll out via
Content-Security-Policy-Report-Only— collect violations for weeks, iterate, then flip to enforcing.” - “Baseline:
default-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'. Tighten from there.” - “Trusted Types is the next layer — forces dangerous DOM sinks (innerHTML, script.src) to go through sanitization policies. Chrome/Edge today; polyfill elsewhere.”
- “Report to a service (Sentry, Report URI) — raw reports are noisy; aggregation + extension filtering needed.”
Cross-references
- XSS (CSP defends against it): 01_xss.md
- Security headers (CSP is one of many): 05_sri_hsts_security_headers.md
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