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.
In depth
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 / 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 6
- “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): XSS — Stored, Reflected, DOM-Based
- Security headers (CSP is one of many): Subresource Integrity, HSTS, Security Headers
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