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:
- Reflected and stored XSS. An attacker gets a
<script>tag or an inline event handler into the page. Without CSP the browser executes it with full origin privileges — reading cookies, session tokens, and DOM. A policy that omits'unsafe-inline'and gates execution on a per-response nonce makes injected<script>blocks inert because the attacker cannot predict the nonce. - DOM-based XSS and
evalsinks. Payloads that flow intoeval(),new Function(),setTimeout("string"), orinnerHTML-driven script insertion. Omitting'unsafe-eval'blocks the string-to-code sinks; nonce/hash enforcement blocks the injected-element sinks. - Data exfiltration and resource injection. Injected
<img>,<form action>,<link>, orfetch()calls that beacon data to an attacker host.connect-src,img-src,form-action, andframe-srcconstrain where the document may send or pull data.
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.
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:
- Unquoted keywords.
script-src selfallows any host literally namedself; the keyword must be'self'. Same for'none','unsafe-inline', nonces, and hashes. - A nonce value that is not unique per response. Reusing a nonce across responses lets an attacker who scrapes one page replay it; the nonce must be a fresh cryptographically random value per response.
'unsafe-inline'alongside a nonce. Modern browsers ignore'unsafe-inline'when a nonce or hash is present (CSP2+ fallback), but legacy browsers honor it — so leaving it in silently weakens the policy for older clients.- Trailing/duplicate directives. A directive name that appears twice is honored once (first occurrence); the duplicate is ignored, which silently drops sources you thought you added.
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;
- Security Impact: Enforces the policy at the reverse proxy so application-layer header stripping or framework defaults cannot weaken it. The
alwaysflag emits the header on error responses (4xx/5xx) too, so injected error pages are also covered. - Verification Command:
curl -sI https://example.com | grep -i content-security-policy
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'"
- Security Impact:
Header always setattaches the policy across all status codes, preventing an uncovered error page from becoming an injection foothold. Requiresmod_headers. - Verification Command:
apachectl -t && curl -sI https://example.com | grep -i content-security-policy
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>
- Security Impact:
customHeadersinweb.configapplies the static policy to all responses served by the site. IIS cannot mint a per-request nonce here — use ASP.NET middleware for nonce-based policies. - Verification Command:
curl -sI https://example.com | findstr /i content-security-policy
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'"],
},
}));
- Security Impact: Generating the nonce in middleware and referencing it inside the Helmet directive guarantees a fresh nonce per request and a consistently formatted header. Render
<script nonce="<%= nonce %>">in templates. - Verification Command:
curl -sI http://localhost:3000 | grep -i content-security-policy
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 });
},
};
- Security Impact: Enforces the policy at the CDN edge before the origin response reaches the client, so an origin misconfiguration cannot drop the header. A Worker can also inject a per-request nonce and rewrite the HTML to stamp it on tags.
- Verification Command:
wrangler deploythencurl -sI https://example.com | grep -i content-security-policy
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.
Related
- Web Security Headers Fundamentals — the parent reference for all security response headers.
- CSP report-uri vs report-to migration guide — modern violation reporting.
- Generating CSP nonces per request — per-response nonce minting and templating.
- Hash-based CSP for inline scripts — authorizing fixed inline snippets by digest.
- CSP strict-dynamic explained — propagating trust to dynamically loaded scripts.
- Cross-Origin Frame Controls & X-Frame-Options —
frame-ancestorsand clickjacking defense.