Introduction
A single unescaped comment, a poisoned innerHTML, one compromised tag-manager script: that’s all it takes for injected JavaScript to start reading session tokens and shipping them elsewhere. For the moment your output encoding fails, there’s Content Security Policy (CSP): a browser-enforced allowlist that tells the client exactly which sources may run scripts, load styles, open connections, or embed frames — so an attacker who manages to inject markup finds the browser refusing to execute it.
Sanitization stays your first defense; CSP is the wall behind it, there to limit the blast radius of any XSS that slips through. What follows is the whole mechanism — directives, nonces, hashes, strict-dynamic, Trusted Types and violation reporting — plus the production rollout sequence that gets a strict policy deployed without taking the site down.
When to Use CSP
Every site that renders HTML should ship a CSP. The question is rarely whether but how strict you can go:
- Any app that renders user input. Comments, profiles, rich text, search results. One missed escape and CSP is what stands between the injection and the user’s session.
- Apps loading third-party scripts. Analytics, chat widgets, A/B tools. CSP bounds what a compromised third party can do on your origin.
- Admin panels and authenticated areas. These carry the most valuable sessions and get attacked the hardest;
default-src 'none'plus explicit exceptions is realistic here. - SPAs and static sites. You don’t need a server to send headers — a
<meta>tag covers most of it, with caveats covered below.
Where the math changes:
- A fully static site with zero inline scripts can go straight to
script-src 'self'— this is the cheapest strong policy that exists. - Legacy apps with inline handlers everywhere can’t jump to a nonce policy without a refactor. Start with report-only, measure, then decide between refactoring to external scripts or living with
'unsafe-hashes'on the attributes you can’t move. - CSP doesn’t replace output encoding. A policy with
'unsafe-inline'onscript-srcstops nothing. And CSP can’t fix DOM clobbering, logic bugs, or data exfiltration through allowed channels likeimg-src https:— treat it as a filter on execution and loading, not a guarantee.
How CSP Works
Your server emits a Content-Security-Policy header alongside the response. The browser parses it before running anything on the page, then evaluates every resource load and inline execution against it:
Two details matter more than the diagram suggests. First, enforcement happens in the browser, so CSP protects end users even when the attacker fully controls the injected markup — the policy never reaches the attacker’s control. Second, blocking and reporting are independent: a policy sent as Content-Security-Policy-Report-Only reports violations without blocking anything, which is how you test a policy safely before enforcing it.
CSP Directives
Fetch directives control resource loading; document and navigation directives control what the page itself may do:
| Directive | Controls |
|---|---|
default-src | Fallback for all fetch directives |
script-src | JavaScript sources and inline script |
script-src-elem | <script> elements and requests only |
script-src-attr | Inline handlers (onclick, onload) only |
style-src | CSS sources and inline style |
style-src-elem / style-src-attr | <style>/<link> vs style="" attributes |
img-src | Image sources |
font-src | Font sources |
connect-src | fetch, XMLHttpRequest, WebSocket, EventSource destinations |
worker-src | Workers — falls back to child-src, then script-src |
frame-src | Sources for nested browsing contexts |
object-src | Plugins — set to 'none' |
media-src | Audio and video sources |
manifest-src | Web app manifest |
child-src | Legacy; superseded by frame-src + worker-src |
base-uri | Allowed <base> tag URIs |
form-action | Allowed form submission destinations |
frame-ancestors | Who may embed this page in a frame |
sandbox | Applies iframe-style sandboxing to the page itself |
require-trusted-types-for | Forces Trusted Types on DOM sinks |
trusted-types | Allowlist of Trusted Type policy names |
report-uri | Legacy violation report destination |
report-to | Reporting API endpoint group |
report-sample | Include a code sample in violation reports |
upgrade-insecure-requests | Auto-upgrade HTTP subresource URLs to HTTPS |
A few gotchas: script-src-elem and script-src-attr fall back to script-src when absent, so you can split inline and external rules without duplicating the policy. frame-ancestors and sandbox do nothing inside a <meta> tag. And report-uri is deprecated in the spec — keep it only as a fallback for older browsers, paired with the Reporting API (below).
A Baseline Policy
Content-Security-Policy:
default-src 'self';
script-src 'self' https://cdn.example.com;
style-src 'self' 'unsafe-inline' https://cdn.example.com;
img-src 'self' data: https:;
font-src 'self' https://cdn.example.com;
connect-src 'self' https://api.example.com wss://ws.example.com;
frame-ancestors 'none';
base-uri 'none';
form-action 'self';
object-src 'none';
upgrade-insecure-requests;
Note what this does not allow: 'unsafe-inline' appears on style-src only, which is a pragmatic concession — CSS injection is far less dangerous than script injection, and nonce-managing every inline style rarely pays off. script-src, meanwhile, omits 'unsafe-inline' entirely — the one keyword that decides whether the policy stops XSS at all.
Nonce-Based CSP
A nonce is a random value generated per response, stamped on the <script> or <style> tag and echoed in the policy. Only elements carrying the matching nonce execute.
// middleware/cspNonce.ts — generate a nonce per request and set the header
import crypto from 'crypto';
function cspNonceMiddleware(req: Request, res: Response, next: NextFunction) {
const nonce = crypto.randomBytes(16).toString('base64');
res.locals.nonce = nonce;
res.setHeader('Content-Security-Policy', [
`default-src 'self'`,
`script-src 'self' 'nonce-${nonce}' https://cdn.example.com`,
`style-src 'self' 'nonce-${nonce}' https://cdn.example.com`,
`img-src 'self' data: https:`,
`font-src 'self' https://cdn.example.com`,
`connect-src 'self' https://api.example.com`,
`frame-ancestors 'none'`,
`base-uri 'none'`,
`form-action 'self'`,
`object-src 'none'`,
`upgrade-insecure-requests`,
].join('; '));
next();
}
app.use(cspNonceMiddleware);
<!-- Tags must carry the matching nonce -->
<script nonce="<%= nonce %>">
console.log('This inline script is allowed');
</script>
<style nonce="<%= nonce %>">
body { margin: 0; }
</style>
Three rules decide whether nonces actually protect you:
- Unpredictable. Use a CSPRNG (
crypto.randomBytes), never a timestamp or a counter. A guessable nonce is an allowlist entry for the attacker. - One per response. A nonce served twice — across requests or between users — becomes a reusable token the attacker only needs to learn once. This also means nonced pages must not be cached as shared HTML — or the nonce has to vary with the cached output.
- Never in markup the attacker can read early. If injected markup can read the DOM before scripts run, a clever payload can lift the nonce attribute off a legitimate tag and reuse it.
'strict-dynamic'(below) was designed partly to close that hole.
Hash-Based CSP
A hash whitelists one inline block by its exact bytes — a fit for static snippets that never change, like bootstrap config or a small theme initializer.
# Hash the exact bytes of the script content
echo -n "console.log('hello');" | openssl dgst -sha256 -binary | openssl base64 -A
# uYeF7eHzVgKpiBg5fikv2NTctmJnxCfX1UhhlrizvNE=
Content-Security-Policy:
script-src 'self' 'sha256-uYeF7eHzVgKpiBg5fikv2NTctmJnxCfX1UhhlrizvNE=';
<!-- Executes only if the content matches the hash byte-for-byte -->
<script>console.log('hello');</script>
The gotchas: the digest covers exact bytes, so changing a space or a quote breaks the block silently. sha256, sha384, and sha512 all work, though sha256 is plenty here. Note that hashes cover <script> and <style> content — allowing inline event handlers (onclick="...") or style="" attributes by hash takes 'unsafe-hashes' on top, which reopens attribute injection and is best avoided.
Choose nonces for anything rendered per request; choose hashes for static snippets or platforms where you can’t control headers per response.
Strict-Dynamic
strict-dynamic extends the trust of a nonced or hashed script to the scripts it loads, without listing each one in the policy:
Content-Security-Policy:
script-src 'nonce-abc123' 'strict-dynamic' 'self' https:;
With strict-dynamic in place:
- Scripts carrying the nonce execute, and scripts they create at runtime (via
createElement, dynamicimport()) inherit the trust. - Browsers that understand it ignore the host allowlist —
'self'andhttps:stay there only so browsers withoutstrict-dynamicsupport fall back to a plain, coarse CSP. - Trust doesn’t propagate to parser-inserted markup, so an attacker who injects
<script src>directly into HTML still gets blocked. That’s precisely the point: it separates “scripts your trusted code loads” from “scripts the markup contains.”
This is the escape hatch that makes a strict CSP survivable for apps with dynamic loaders, module federation, or third-party widgets that pull in their own dependencies — you nonce the loader once instead of allowlisting a moving CDN inventory.
Trusted Types
CSP’s newer sibling targets DOM XSS specifically. require-trusted-types-for 'script' makes the browser refuse string assignments into dangerous sinks — innerHTML, eval, script.src, document.write — unless the string went through a Trusted Types policy:
Content-Security-Policy:
require-trusted-types-for 'script';
trusted-types default dompurify;
// A policy decides which sanitized values may reach DOM sinks
const sanitizer = trustedTypes.createPolicy('dompurify', {
createHTML: (dirty) => DOMPurify.sanitize(dirty, { RETURN_TRUSTED_TYPE: true }),
});
element.innerHTML = sanitizer.createHTML(userInput); // allowed
element.innerHTML = userInput; // blocked
The trusted-types directive restricts which policy names may be created, so an attacker can’t mint their own createHTML that returns input unchanged. Adoption is still uneven across stacks, but on apps that process untrusted HTML in the browser it’s the strongest DOM-XSS control available — and it works alongside, not instead of, script-source CSP.
CSP Reporting
Reporting API (current)
The Reporting API sends violations as JSON batches to a named endpoint group:
Reporting-Endpoints: csp-endpoint="https://example.com/api/csp-reports"
Content-Security-Policy: ...; report-to csp-endpoint; report-sample;
report-sample attaches a ~40-character snippet of the offending code to each report, which saves real time when you’re hunting which inline block triggered a violation.
report-uri (legacy fallback)
report-uri is deprecated in the CSP spec but still the only mechanism some older browsers honor, and it’s what most hosted report collectors (like report-uri.com) ingest:
Content-Security-Policy: ...;
report-uri /api/csp-reports;
report-to csp-endpoint;
You can safely send both — browsers pick the mechanism they support. Note the Report-To header itself is deprecated in favor of Reporting-Endpoints; new deployments should skip it.
Report endpoint
The two mechanisms ship different payloads: the legacy endpoint gets one {"csp-report": {...}} object per violation, while Reporting API reports arrive batched as an array of {"type": "csp-violation", "body": {...}}. Accept both:
// routes/cspReports.ts — accepts legacy and Reporting API payloads
app.post('/api/csp-reports',
express.json({ type: ['application/csp-report', 'application/reports+json'] }),
(req, res) => {
const payload = req.body;
const reports = Array.isArray(payload)
? payload.map((r) => r.body)
: [payload['csp-report']];
for (const report of reports) {
console.warn('CSP violation:', {
documentUri: report['document-uri'],
directive: report['effective-directive'] ?? report['violated-directive'],
blockedUri: report['blocked-uri'],
disposition: report.disposition,
sample: report['script-sample'],
});
}
res.status(204).end();
});
Pipe reports to a log stream you actually watch. A CSP nobody monitors is a guess, not a policy.
CSP Without a Server (Meta Tag)
Static hosting that can’t set response headers — GitHub Pages, plain S3, some CDNs — can still ship a policy through markup:
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; object-src 'none'; base-uri 'none';">
Three limitations to know: frame-ancestors, sandbox, and reporting directives are ignored inside <meta> (they need a real header), the policy can’t vary per request so nonces are off the table (hashes still work), and a second <meta> CSP later in the document only tightens the policy — which is useful for hardening specific pages.
Production Deployment
Step 1: Inventory what the page loads
# Chrome DevTools → Network: filter by domain and note every origin
# Lighthouse → Best Practices flags missing/weak CSP
# Faster: run Google's CSP Evaluator on an existing header,
# or report-uri.com's generator to draft one
List every external domain for scripts, styles, images, fonts, API calls, WebSockets, and frames. Most CSP failures come from a forgotten source discovered in production, not from a wrong directive.
Step 2: Deploy report-only
app.use((req, res, next) => {
const nonce = crypto.randomBytes(16).toString('base64');
res.locals.nonce = nonce;
res.setHeader('Content-Security-Policy-Report-Only', [
`default-src 'self'`,
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`,
`style-src 'self' 'nonce-${nonce}'`,
`img-src 'self' data: https:`,
`font-src 'self' https://cdn.example.com`,
`connect-src 'self' https://api.example.com wss://ws.example.com`,
`frame-ancestors 'none'`,
`base-uri 'none'`,
`form-action 'self'`,
`report-uri /api/csp-reports`,
`report-to csp-endpoint`,
].join('; '));
next();
});
Step 3: Fix what the reports show
Common violations and their fixes:
1. Inline script without nonce
→ Nonce the tag or move it to an external file
2. Inline style without nonce
→ Nonce it, move it, or accept 'unsafe-inline' on style-src only
3. eval() call
→ Remove it; 'unsafe-eval' is a last resort, not a fix
4. Script from an unlisted domain
→ Allowlist it, or load it through a nonced loader with 'strict-dynamic'
5. WebSocket to an unlisted domain
→ Add the wss:// origin to connect-src
6. data: or blob: images
→ Add data: / blob: to img-src deliberately, not globally
7. Inline event handler (onclick, onload)
→ Refactor to addEventListener — 'unsafe-hashes' exists but weakens the policy
Let report-only run for days, not hours — infrequently hit pages (checkout, admin, error flows) surface violations late.
Step 4: Enforce
app.use((req, res, next) => {
const nonce = crypto.randomBytes(16).toString('base64');
res.locals.nonce = nonce;
res.setHeader('Content-Security-Policy', [
`default-src 'self'`,
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`,
`style-src 'self' 'nonce-${nonce}'`,
`img-src 'self' data: https:`,
`font-src 'self' https://cdn.example.com`,
`connect-src 'self' https://api.example.com wss://ws.example.com`,
`frame-ancestors 'none'`,
`base-uri 'none'`,
`form-action 'self'`,
`object-src 'none'`,
`upgrade-insecure-requests`,
].join('; '));
next();
});
Leave the report directives in when you switch to enforce. Violations after enforcement are signal: either a real attack attempt or a resource you missed.
Framework Integration
Express with helmet
Helmet’s CSP middleware builds the header from a directives object, including nonce functions:
import helmet from 'helmet';
app.use(helmet.contentSecurityPolicy({
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", (req, res) => `'nonce-${res.locals.nonce}'`, "'strict-dynamic'"],
styleSrc: ["'self'", (req, res) => `'nonce-${res.locals.nonce}'`],
imgSrc: ["'self'", 'data:', 'https:'],
fontSrc: ["'self'", 'https://cdn.example.com'],
connectSrc: ["'self'", 'https://api.example.com', 'wss://ws.example.com'],
frameAncestors: ["'none'"],
baseUri: ["'none'"],
formAction: ["'self'"],
objectSrc: ["'none'"],
upgradeInsecureRequests: [],
},
}));
The empty array for upgradeInsecureRequests is intentional — it’s how helmet emits a valueless directive.
Next.js (App Router)
next.config.js is evaluated at build time — a nonce can’t live there. Per the Next.js docs, the nonce is generated in middleware, forwarded through a request header, and read by Server Components:
// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
export function middleware(request: NextRequest) {
const nonce = Buffer.from(crypto.getRandomValues(new Uint8Array(16))).toString('base64');
const csp = [
`default-src 'self'`,
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`,
`style-src 'self' 'nonce-${nonce}'`,
`img-src 'self' data: https:`,
`font-src 'self'`,
`object-src 'none'`,
`base-uri 'none'`,
`form-action 'self'`,
`frame-ancestors 'none'`,
`upgrade-insecure-requests`,
].join('; ');
const requestHeaders = new Headers(request.headers);
requestHeaders.set('x-nonce', nonce);
requestHeaders.set('Content-Security-Policy', csp);
const response = NextResponse.next({ request: { headers: requestHeaders } });
response.headers.set('Content-Security-Policy', csp);
return response;
}
// app/layout.tsx — read the nonce and pass it to scripts
import { headers } from 'next/headers';
import Script from 'next/script';
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const nonce = (await headers()).get('x-nonce');
return (
<html lang="en">
<body>
{children}
<Script src="https://cdn.example.com/app.js" nonce={nonce ?? undefined} strategy="beforeInteractive" />
</body>
</html>
);
}
Reading headers() makes the route dynamic, which is the trade-off nonces impose everywhere — there’s no way to have per-request nonces and full static caching on the same response.
Known Bypasses and Limits
Every entry you allow is a hole the policy keeps open. The weak points show up here:
- JSONP and AngularJS gadgets on allowlisted domains. A hosted library with a JSONP endpoint (
cdn.example.com/jsonp?callback=) or an old AngularJS version can be abused to execute attacker-chosen callbacks — the reason Google’s CSP research pushedstrict-dynamicover domain allowlists. - Nonce theft. Injected markup that runs before your scripts can sometimes read nonce attributes off legitimate tags and clone them.
strict-dynamicplus parser-blocking makes this much harder. - Exfiltration through allowed channels.
img-src https:permits requests to any HTTPS host — an attacker who can’t run JS can still leak data through injected<img>or CSS. Tightenimg-srcandconnect-srcto named origins where you can. <base>injection. Withoutbase-uri 'none', one injected<base href>resolves every relative URL against the attacker’s host — your own script tags included.- What CSP can’t see. DOM clobbering, logic flaws, prototype pollution through allowed scripts, and anything a permitted third-party script decides to do. CSP shrinks the attack surface; it won’t audit whatever walks through the gates you opened.
Best Practices
- Start with report-only mode and let it collect violations before enforcing anything.
- Prefer nonces for per-request rendering, hashes for static inline blocks; pick one mechanism and stay consistent.
- Use
strict-dynamiconce your scripts load their own dependencies — it removes the moving-target domain allowlist. - Lock down
frame-ancestors 'none'for clickjacking,base-uri 'none'for<base>injection,object-src 'none'for plugins, andform-action 'self'for form redirection. - Enable
upgrade-insecure-requestsso mixed-content references can’t downgrade the page. - Monitor reports with alerting, not just logging — a violation spike after a deploy usually means a broken feature, not an attack.
- Re-run a policy evaluator (Google’s CSP Evaluator, Mozilla Observatory) after every significant change to the header.
Common Mistakes
'unsafe-inline'onscript-src. With it present the policy stops nothing. If a legacy page can’t live without it, scope the rule to that page, not the site.- Enforcing before measuring. A strict policy deployed cold breaks production. Report-only exists precisely for the discovery phase — use it.
- Reusing or predicting nonces. A nonce from
Date.now()or shared across responses gives attackers a working allowlist entry. CSPRNG, once per response, never cached. - Allowlisting whole CDNs.
script-src https://cdn.example.comtrusts every library and JSONP endpoint on that host, including stale ones with known bypass gadgets. - Forgetting
connect-src. The firstfetchto an unlisted API fails at runtime and users report a broken app, not a CSP violation. - Ignoring reports. A report endpoint nobody reads turns CSP into theater — you enforced something and never checked whether it works.
See Also
- MDN — Content-Security-Policy reference
- W3C — Content Security Policy Level 3 spec
- OWASP — Content Security Policy Cheat Sheet
- Google — CSP Evaluator
- Companion code: the CSP companion repo has runnable versions of the nonce middleware, the dual-format report endpoint, and the framework examples from this page
- Web Security Headers — CSP alongside HSTS, X-Frame-Options, and the rest of the header stack
- CORS Security — the other half of browser-origin enforcement
Frequently Asked Questions
What is Content Security Policy?
A response header (or <meta> policy) that tells the browser which sources may load and execute each resource type — scripts, styles, images, connections, frames. When injected markup tries to run an unauthorized script, the browser blocks it before execution, which is what makes CSP the strongest second line of defense against XSS.
Nonce vs hash: which should I use?
Nonces are random per-response values for dynamic pages; hashes are digests of exact content for static inline snippets. Nonces need server-side generation on every response — which rules out full-page HTML caching — while hashes can be computed at build time and shipped on a CDN. If your page is rendered per request, use nonces; if it's static, use hashes or move the code external.
Does strict-dynamic make my allowlist useless?
Yes, in every browser that understands it — that's the intended trade. strict-dynamic makes the browser ignore host sources ('self', domains, schemes) and trust whatever your nonced scripts load. Browsers without support fall back to enforcing the allowlist as a plain CSP, so you get strong enforcement where it counts and degraded-but-working behavior elsewhere.
Can CSP stop all XSS?
No. It stops script execution from unauthorized sources — nothing more. A policy with 'unsafe-inline' on script-src stops nothing at all; allowed third-party scripts can still misbehave; data can still leak through permitted img-src or connect-src channels; and DOM clobbering or logic bugs are outside its reach. Treat CSP as blast-radius reduction on top of output encoding and sanitization, not as the primary fix.
Can I use CSP on a static site?
Yes, through <meta http-equiv="Content-Security-Policy">. You lose frame-ancestors, sandbox, and reporting (all header-only), and you can't use per-request nonces — but script-src 'self' plus hashes covers most static sites, which rarely need inline scripts anyway.
How do I debug a CSP that's breaking the page?
Deploy it as Content-Security-Policy-Report-Only with report-sample enabled and read the violations — each report names the directive, the blocked URL, and a snippet of the offending code. Chrome DevTools also logs every blocked resource to the console with the directive that killed it.
Related Resources
CORS Security: Origins, Headers, Preflight, Credentials
How CORS actually works: same-origin policy, preflight requests, CORS headers, credential handling, misconfigurations that break production, and the security rules that matter.
GuideOAuth2 and OIDC: Authorization Code, PKCE, Token Validation
Master OAuth2 and OpenID Connect for production: authorization code flow with PKCE, token validation, refresh tokens, scopes, JWT verification, and security best practices.
RecipeConfigure HTTP Security Headers with Helmet in Node.js
Set security HTTP headers in Express apps with Helmet — CSP, HSTS, X-Frame-Options, X-Content-Type-Options, and CORS for OWASP-compliant web security
GuideComplete Guide to Web Security Headers
Implement CSP, HSTS, X-Frame-Options, and secure headers. Covers content security policy, CORS, referrer policy, permissions policy, and testing with security scanners.
GuideEncryption at Rest: AES-256, KMS, Envelope Encryption
Encrypt stored data correctly: AES-256-GCM, KMS-managed keys, envelope encryption, key rotation, and field-level encryption for databases and backups.