Content-Security-Policy (CSP)

This is the implementation reference for the Content-Security-Policy (CSP) response header, part of the Web Security Headers Fundamentals reference. CSP is a declarative allowlist that the browser enforces on every resource fetch and script execution a document attempts. It converts cross-site scripting from a code-execution problem into a policy-violation problem: even when an attacker injects markup, the browser refuses to execute scripts, load styles, or open connections that the policy did not authorize. This page covers the directive grammar, the nonce and hash validation model, platform configuration for Nginx, Apache, IIS, Node/Express (Helmet), and Cloudflare, and a Content-Security-Policy-Report-Only rollout that ships a policy without breaking production.

Threat Model & Protocol Mechanics

CSP exists to contain the blast radius of HTML and script injection. Three attack classes are in scope:

CSP is specified by the W3C in Content Security Policy Level 3 (the current Editor’s Draft), which supersedes the CSP Level 2 Recommendation. Level 2 introduced nonces and hashes for inline content; Level 3 added 'strict-dynamic', the report-to directive, and worker-src. The enforcement model is entirely browser-side and per-response: the browser parses the Content-Security-Policy header on the document response and applies it to that document only. The policy is not cached or inherited across navigations — each response must carry its own header. A policy delivered by HTTP header always wins over one delivered by <meta http-equiv>, and the <meta> form cannot express frame-ancestors, report-uri, or sandbox.

CSP interlocks with sibling headers. The frame-ancestors directive is the modern replacement for X-Frame-Options: it controls who may embed this document in a frame and supports a list of origins rather than the single allow/deny model of the legacy header. CSP pairs with transport hardening from HTTP Strict Transport Security (HSTS) — CSP governs what loads, HSTS guarantees it loads over TLS so a network attacker cannot strip the policy header in transit.

CSP script-src enforcement decision flow A decision tree the browser follows for each script: it checks for a matching nonce, then a matching hash, then strict-dynamic propagation, then the source allowlist, and otherwise blocks and reports. Script wants to run browser evaluates script-src nonce matches header? nonce-{value} hash matches body? sha256-{base64} trusted by strict-dynamic? parent was allowed origin in allowlist? Execute Block + report report-to / report-uri any match: allow no match: deny
For each script the browser stops at the first matching gate — nonce, hash, strict-dynamic propagation, or source allowlist — and blocks (and reports) anything that matches none.

Directive Syntax & Spec

A policy is a semicolon-separated list of directives; each directive is a name followed by space-separated source expressions. Quote keyword sources ('self', 'none', 'nonce-…'); never quote host or scheme sources.

Content-Security-Policy: default-src 'self'; script-src 'nonce-r4nd0m' 'strict-dynamic' https:; style-src 'self'; img-src 'self' data: https://assets.example.com; connect-src 'self' https://api.example.com; frame-ancestors 'none'; base-uri 'self'; object-src 'none'; report-to csp-endpoint
Directive Type Default (no directive) Security Impact When to Deviate
default-src Fetch (fallback) none — fetches unrestricted Backstop for any fetch directive you omit Always set to 'self' or 'none'; deviate only with full per-resource directives
script-src Fetch falls back to default-src The primary XSS gate; controls JS execution Use 'nonce-…' + 'strict-dynamic'; avoid 'unsafe-inline' / 'unsafe-eval'
style-src Fetch falls back to default-src Blocks injected <style> and style attributes Use nonces or hashes; CSS injection enables exfiltration via selectors
img-src Fetch falls back to default-src Limits beacon/exfil via <img> Add data: only if you embed inline images; it widens the surface
connect-src Fetch falls back to default-src Constrains fetch, XHR, WebSocket, sendBeacon Enumerate exact API + WS origins; never https://*
frame-ancestors Navigation none — embeddable anywhere Clickjacking defense; replaces X-Frame-Options 'none' to forbid framing, or list trusted embedders
frame-src Fetch falls back to default-src Controls what you may embed Scope to specific embed providers
base-uri Document none — <base> unrestricted Stops <base href> hijacking that re-points relative URLs Always 'self' or 'none'
object-src Fetch falls back to default-src Blocks <object>/<embed> plugin payloads Almost always 'none'
form-action Navigation none — forms post anywhere Stops injected forms from POSTing credentials offsite Scope to your own origins
report-to Reporting no reporting Routes violations to a named endpoint group Pair with report-uri during migration
report-uri Reporting (deprecated) no reporting Legacy per-violation POST Keep only for browsers without Reporting API

Common malformed-syntax gotchas:

The nonce and hash model is the core of a strong policy. A nonce is a per-response random token emitted both in the header (script-src 'nonce-r4nd0m') and on each trusted tag (<script nonce="r4nd0m">); the browser runs only tags whose nonce matches. A hash ('sha256-…') authorizes a specific inline script by the base64 digest of its exact body, with no markup change required. 'strict-dynamic' extends trust: any script the browser already trusted (via nonce or hash) may then load further scripts it creates, so you do not have to allowlist every transitive CDN. These three mechanisms are covered in depth in generating CSP nonces per request, hash-based CSP for inline scripts, and CSP strict-dynamic explained.

Platform-Specific Implementation

Deliver CSP as a response header, not a <meta> tag, so it covers every directive and every response path. Set it at the layer closest to the response that can inject a per-request nonce; for static policies, the edge or web server is sufficient.

Nginx

add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://cdn.example.com; object-src 'none'; base-uri 'self'; frame-ancestors 'none'" always;

Apache

Header always set Content-Security-Policy "default-src 'self'; script-src 'self' https://cdn.example.com; object-src 'none'; base-uri 'self'; frame-ancestors 'none'"

IIS

<system.webServer>
  <httpProtocol>
    <customHeaders>
      <add name="Content-Security-Policy" value="default-src 'self'; script-src 'self' https://cdn.example.com; object-src 'none'; base-uri 'self'; frame-ancestors 'none'" />
    </customHeaders>
  </httpProtocol>
</system.webServer>

Node/Express (Helmet)

const helmet = require('helmet');
const crypto = require('crypto');

app.use((req, res, next) => {
  res.locals.nonce = crypto.randomBytes(16).toString('base64');
  next();
});

app.use(helmet.contentSecurityPolicy({
  directives: {
    defaultSrc: ["'self'"],
    scriptSrc: ["'self'", (req, res) => `'nonce-${res.locals.nonce}'`, "'strict-dynamic'"],
    objectSrc: ["'none'"],
    baseUri: ["'self'"],
    frameAncestors: ["'none'"],
  },
}));

Cloudflare

Use a Worker (or a Transform Rule for a static policy) to set the header at the edge:

export default {
  async fetch(request) {
    const response = await fetch(request);
    const headers = new Headers(response.headers);
    headers.set(
      'Content-Security-Policy',
      "default-src 'self'; script-src 'self' https://cdn.example.com; object-src 'none'; base-uri 'self'; frame-ancestors 'none'"
    );
    return new Response(response.body, { status: response.status, headers });
  },
};

Verification & Diagnostic Workflows

Confirm the header is present, parses cleanly, and blocks what it should.

# 1. Header present and well-formed
curl -sI https://example.com | grep -i content-security-policy

# 2. Inspect the full policy without truncation
curl -s -D - -o /dev/null https://example.com | grep -i 'content-security-policy'

In the browser, open DevTools → Console; CSP refusals log as Refused to execute inline script because it violates the following Content Security Policy directive: …. The Network tab shows blocked requests, and DevTools → Application/Security surfaces the active policy. The Reporting API’s structured reports go to the endpoint configured by report-to; see the report-uri vs report-to migration guide for collector setup.

A CI/CD assertion that fails the build if the header is missing or contains unsafe-inline:

csp=$(curl -fsSI https://staging.example.com | grep -i '^content-security-policy:')
[ -n "$csp" ] || { echo "FAIL: no CSP header"; exit 1; }
echo "$csp" | grep -qi "unsafe-inline" && { echo "FAIL: unsafe-inline present"; exit 1; }
echo "OK: $csp"

Work this pre-enforcement checklist before switching from Content-Security-Policy-Report-Only to enforcement:

Troubleshooting, Misconfigurations & Safe Rollback

Roll out with Content-Security-Policy-Report-Only first. The Report-Only header makes the browser evaluate the policy and emit violation reports without blocking anything, so you collect the real violation set from production traffic before any user-visible breakage. Run it 1–2 weeks, drive reports to zero false positives, then switch the header name to Content-Security-Policy to enforce.

Symptom Cause Fix
All inline scripts blocked after enforcing Policy has no 'unsafe-inline', nonce, or hash Add a per-response nonce and stamp it on trusted tags, or use hashes
Third-party script fails to load its dependencies Loader injects further scripts not in the allowlist Add 'strict-dynamic' so nonce-trusted scripts may load children
Policy ignored entirely Delivered via <meta> with frame-ancestors/report-uri, or unquoted keywords Deliver by HTTP header; quote 'self'/'none'
Styles break style-src omits 'unsafe-inline' and inline styles/attributes used Move to external CSS, or add style hashes/nonces
script-src * or 'unsafe-eval' present Wildcard/eval nullifies XSS protection Replace * with explicit origins; remove 'unsafe-eval' and refactor eval sinks
Duplicate Content-Security-Policy headers Both edge and origin set it; browser enforces all policies (intersection) Set the header at exactly one layer

Safe rollback: if enforcement breaks production, revert by renaming the live header back to Content-Security-Policy-Report-Only (Nginx/Apache: change the header name in the directive and reload; Cloudflare: edit the Worker/Transform Rule). The page functions immediately while reports keep flowing, so you can fix the policy without an outage.

Frequently Asked Questions

Do I need a nonce if I already have a hash-based policy? No. Nonces and hashes are alternative ways to authorize inline content. Hashes suit static inline scripts that never change (no server work per request); nonces suit dynamic pages where script bodies vary. Many sites use nonces for first-party inline scripts and hashes for a small set of fixed snippets.

Why is my policy enforced even though I set Report-Only? You likely have both headers present, or an edge layer is adding the enforcing Content-Security-Policy while your origin adds Report-Only. The browser enforces every Content-Security-Policy header it receives. Send exactly one of the two header names from one layer.

Does default-src 'self' cover scripts? Only if you omit script-src. default-src is the fallback for fetch directives you do not specify. The moment you add script-src, it fully governs scripts and default-src no longer applies to them — a frequent cause of “I tightened script-src but eval still works.”

Will CSP stop all XSS? No. CSP contains injection by blocking unauthorized execution, but a policy with 'unsafe-inline', a wildcard script-src, or an open JSONP/object-src provides little protection. CSP is a second line of defense; output encoding and input validation remain mandatory.

Can I set CSP in a <meta> tag? You can for fetch directives, but <meta> cannot express frame-ancestors, report-uri/report-to, or sandbox, and it applies only after the parser reaches it. Prefer the HTTP header for complete, early coverage.