Home SECURITY

Content Security Policy: directives, nonces, and reporting

CSP is the response header that tells the browser which scripts, styles, frames, and connects are allowed to run. A correctly authored CSP eliminates whole classes of XSS attacks at the cost of a careful inventory of trusted sources.

What CSP is

A Content Security Policy is a response header (or <meta http-equiv>) that lists the origins, hashes, or nonces from which the browser will load each kind of resource. The browser blocks any load that does not match the policy.

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-rAnD0m';
  style-src 'self' 'unsafe-inline';
  img-src 'self' https: data:;
  connect-src 'self' https://analytics.example;
  frame-ancestors 'self';
  base-uri 'none';
  form-action 'self';
  report-to csp-endpoint;

The policy applies per page load. Every directive defaults to default-src if unset, except frame-ancestors and form-action, which have their own defaults.

Why CSP matters

CSP closes the exploit cliff of cross-site scripting (XSS). A classic XSS payload — <script>fetch('/api/admin/...')</script> injected into a comment field — does nothing under script-src 'self' 'nonce-…' because the injected script lacks the page’s nonce. About 60% of OWASP-Top-10 XSS findings in the OWASP 2021 statistics would be defanged by a strict CSP.

CSP also defends against:

  • Click-jacking via frame-ancestors.
  • Form-hijacking via form-action.
  • <base> tag injection via base-uri.
  • Unintended subresource loads via default-src plus per-type directives.

The three modes of trust

Inside script-src, the three trust mechanisms are:

  1. Origin'self', https://cdn.example. Permits all scripts from that origin. Coarse; the easiest to author and the easiest to abuse if any script on that origin is itself compromised.
  2. Nonce'nonce-<base64>'. The server emits a fresh random nonce per response, embeds it in the header and in each <script nonce="..."> tag. Per-response, per-script.
  3. Hash'sha256-<base64>', 'sha384-...'. The hash of the script body. Suitable for static inline scripts whose content does not change.

Modern recommendations from the Mozilla CSP guide favour nonces over origin allowlists for script-src because allowlists tend to drift wider over time; in Lukas Weichselbaum’s 2018 CSP study, about 95% of allowlist-based CSPs were bypassable via a single trusted-origin script.

strict-dynamic

'strict-dynamic' says: trust whatever a nonce-validated script loads. Once you trust the entry-point script via nonce, transitive loads it makes are also trusted, regardless of origin. This makes script bundlers and dynamic-import patterns work without listing every chunk URL:

Content-Security-Policy:
  script-src 'nonce-rAnD0m' 'strict-dynamic';
  object-src 'none';
  base-uri 'none';

The combination is the recommended modern policy. Cross-engine support reached interop in 2018.

Reporting

Content-Security-Policy-Report-Only runs the policy in audit mode: violations are reported but not blocked. Useful for gradual rollout.

report-to <group> plus a Reporting-Endpoints: <group>="<url>" header collects violation reports as JSON. Per the Reporting API spec, each report includes the directive that fired, the blocked URI, the source file and line of the inline violation, and a sample of the matched element. About 0.1% of users encounter a CSP-blocked resource on a typical site, but the reports surface 100% of production XSS attempts.

Common pitfalls

  • 'unsafe-inline' in script-src. Defeats almost the entire point of CSP. Use nonces instead.
  • 'unsafe-eval' in script-src. Permits eval, new Function, and setTimeout(stringArg). Most modern bundlers no longer require it.
  • Forgetting object-src 'none'. <object> and <embed> bypass script-src in older engine versions. Lock them down.
  • Forgetting base-uri. A <base href="..."> injection redirects every relative URL to the attacker’s origin.
  • Wildcard schemes. script-src https: allows any HTTPS host. Equivalent to no CSP for script.
  • Inline event handlers. <button onclick="…"> is treated as inline script and blocked. Bind handlers via JS instead.
  • <style> tag with no nonce. Locks down style-src similarly. Either nonce inline <style> or move to external stylesheets.
  • Mixed-content loads on HTTPS pages. Browsers upgrade or block, depending on CSP. upgrade-insecure-requests directive rewrites http: references to https:.

Cross-engine support

CSP Level 1 (origin-based) reached interop in 2014. CSP Level 2 (nonces, hashes) reached interop in 2016. CSP Level 3 ('strict-dynamic', report-to, Trusted Types interop) reached roughly 95% interop by 2024 across Chromium (Blink), WebKit, and Gecko.

caniuse for CSP3 reports global support of about 97% for L2 features. L3 is at about 88% — Trusted Types and report-to lag in WebKit.

Further reading