StackPractices
advanced By Mathias Paulenko

Content Security Policy: CSP Headers, Nonces, Hashes

Deploy Content Security Policy without breaking your site: directives, nonces, hashes, strict-dynamic, reporting, and framework integration.

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' on script-src stops nothing. And CSP can’t fix DOM clobbering, logic bugs, or data exfiltration through allowed channels like img-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:

flowchart diagram: Browser requests page

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:

DirectiveControls
default-srcFallback for all fetch directives
script-srcJavaScript sources and inline script
script-src-elem<script> elements and requests only
script-src-attrInline handlers (onclick, onload) only
style-srcCSS sources and inline style
style-src-elem / style-src-attr<style>/<link> vs style="" attributes
img-srcImage sources
font-srcFont sources
connect-srcfetch, XMLHttpRequest, WebSocket, EventSource destinations
worker-srcWorkers — falls back to child-src, then script-src
frame-srcSources for nested browsing contexts
object-srcPlugins — set to 'none'
media-srcAudio and video sources
manifest-srcWeb app manifest
child-srcLegacy; superseded by frame-src + worker-src
base-uriAllowed <base> tag URIs
form-actionAllowed form submission destinations
frame-ancestorsWho may embed this page in a frame
sandboxApplies iframe-style sandboxing to the page itself
require-trusted-types-forForces Trusted Types on DOM sinks
trusted-typesAllowlist of Trusted Type policy names
report-uriLegacy violation report destination
report-toReporting API endpoint group
report-sampleInclude a code sample in violation reports
upgrade-insecure-requestsAuto-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:

  1. Unpredictable. Use a CSPRNG (crypto.randomBytes), never a timestamp or a counter. A guessable nonce is an allowlist entry for the attacker.
  2. 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.
  3. 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, dynamic import()) inherit the trust.
  • Browsers that understand it ignore the host allowlist — 'self' and https: stay there only so browsers without strict-dynamic support 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 pushed strict-dynamic over domain allowlists.
  • Nonce theft. Injected markup that runs before your scripts can sometimes read nonce attributes off legitimate tags and clone them. strict-dynamic plus 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. Tighten img-src and connect-src to named origins where you can.
  • <base> injection. Without base-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-dynamic once 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, and form-action 'self' for form redirection.
  • Enable upgrade-insecure-requests so 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' on script-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.com trusts every library and JSONP endpoint on that host, including stale ones with known bypass gadgets.
  • Forgetting connect-src. The first fetch to 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

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.