Content Security Policy (CSP)

6 min read source

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 with nonce="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' or api.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' — allow eval, 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.

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-Endpoints header). 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' in style-src OR (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, like frame-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-inline for 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' in script-src defeats 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

Further reading