About Posts Series Contact
Security 12 min read

Content Security Policy: Not Just a Header

CSP is the second line of defence after XSS prevention fails. This post goes beyond "add the header" — nonce strategy, strict-dynamic, bypass patterns, and how to deploy without breaking your app.

Honey Sharma

DOMPurify 3.0.8 had a mutation XSS edge case involving SVG foreign object elements. It was disclosed, patched, and fixed in 3.0.9. But DevForum was running 3.0.8, pinned, with no automated updates. A researcher found the edge case and submitted a payload through the comment form.

The payload passed DOMPurify. It landed in the DOM. It tried to execute.

Nothing happened.

DevForum had deployed a Content Security Policy six months earlier. The CSP did not know the payload was malicious — it just knew the script didn’t have a valid nonce. The browser blocked it before a single line of attacker JavaScript ran. The second line of defence caught what the first missed.


What CSP Actually Does

A Content-Security-Policy response header tells the browser: here is a list of what is permitted to execute, load, or connect on this page. Anything not on the list is refused, regardless of what the HTML says.

The browser enforces this at execution time. Not the server, not a proxy, not a CDN — the browser. After the HTML is parsed, before any resource loads or any script runs, the browser checks the policy. No matching source → no execution.

This is what makes CSP a meaningful second line of defence. Even if an attacker gets a payload into the DOM, they cannot execute it without also being able to bypass the browser’s own enforcement.

Header Anatomy

The policy is a single response header. Its value is a semicolon-separated list of directives, each of which is a directive name followed by a space-separated list of source values.

Content-Security-Policy Header — DevForum
Content-Security-Policy
default-src 'self'
script-src 'nonce-{n}' 'strict-dynamic'
style-src 'self' fonts.googleapis.com
img-src 'self' data:
connect-src 'self' api.devforum.com
frame-src 'none'
base-uri 'self'
object-src 'none'
form-action 'self'
Each directive controls a specific resource type. Directives not listed fall back to default-src. The script-src nonce strategy is the core of this policy.

A few directives deserve extra attention:

default-src 'self' is the fallback. Any resource type not explicitly covered by another directive falls back here. It’s the safety net — set it conservatively.

base-uri 'self' is frequently omitted and frequently exploited. An attacker who can inject <base href="https://evil.com/"> into your page rewrites every relative URL on it. Your /api/login fetch now goes to evil.com/api/login. base-uri 'self' prevents this entirely.

object-src 'none' eliminates the entire plugin execution context. Flash is dead, but <object> and <embed> remain valid HTML. Set this to 'none' and forget about it.

The 'unsafe-inline' Problem

Before covering the right way to allow scripts, it’s worth being explicit about the wrong way.

Adding 'unsafe-inline' to script-src means any inline script on the page may execute — including any inline script an attacker manages to inject. It is the equivalent of no CSP for XSS purposes.

Similarly, 'unsafe-eval' allows eval(), setTimeout(string), and new Function(string). Unless you have a bundler that requires it (some older webpack configs do), this should never appear in a production policy.

How the Browser Evaluates CSP

When a script wants to execute, the browser runs this decision tree:

Browser CSP Evaluation — Does This Script Execute?
no yes no yes no yes Script CSP header present? Executes (no policy) script-src defined? Use default-src Script matches a source? BLOCKED Executes
Without a CSP header, all scripts execute. With one, each script is checked against the policy before execution.

The check happens at execution time, for every script — inline and external. The browser does not distinguish between “trusted” and “injected” scripts in the HTML. The policy does.

Nonce-Based CSP

A nonce is a random token generated fresh for each HTTP response. It lives in two places simultaneously: the Content-Security-Policy header and the nonce attribute of every <script> tag the server renders. The browser only executes scripts where these two values match.

The key property: an attacker who injects a <script> into the page cannot know the nonce. It was generated on the server for this specific request and will never be reused.

Nonce-Based CSP — Per-Request Token Flow
GET /articles/123 nonce = crypto.randomBytes(16).toString('base64url') 200 OK CSP: script-src 'nonce-r4nd0mXYZ' 'strict-dynamic'<script nonce="r4nd0mXYZ"> — nonce matches, executes legitimate script<script> (injected, no nonce) — BLOCKED XSS payload Browser DevForum Server
The nonce is different on every page load. A script injected by an attacker has no way to discover what the current nonce is.

Express middleware that generates and injects the nonce:

import crypto from 'crypto';

function cspMiddleware(req, res, next) {
  const nonce = crypto.randomBytes(16).toString('base64url');
  res.locals.nonce = nonce;

  res.setHeader(
    'Content-Security-Policy',
    [
      `script-src 'nonce-${nonce}' 'strict-dynamic'`,
      "style-src 'self' fonts.googleapis.com",
      "img-src 'self' data:",
      "connect-src 'self' api.devforum.com",
      "base-uri 'self'",
      "object-src 'none'",
      "form-action 'self'",
      "default-src 'self'",
    ].join('; ')
  );

  next();
}

Every <script> tag in the server-rendered HTML then includes the nonce:

<script nonce="<%= nonce %>">
  // App bootstrap code
</script>

Hash-Based CSP

Instead of a per-request token, you can allowlist a specific script by its SHA-256 content hash. The browser computes the hash of the inline script and checks it against the policy.

Generate the hash for a specific inline script:

$ printf 'console.log("DevForum v2.1 loaded")' | openssl dgst -sha256 -binary | openssl base64 -A
K7fGRyPFoB+5wNHikKF2gqrOaSS3OkR9N8dKuivFm4c=

If you don’t have openssl available locally, paste the script URL into srihash.org — it fetches the file and generates the hash for you.

The resulting CSP directive:

Content-Security-Policy: script-src 'sha256-K7fGRyPFoB+5wNHikKF2gqrOaSS3OkR9N8dKuivFm4c='

The browser will only execute inline scripts whose content hashes to this exact value. Any character change — including a trailing space or newline — produces a different hash and blocks execution.

Strict CSP vs Domain Allowlists

Most CSP tutorials tell you to list the domains your scripts come from:

Content-Security-Policy: script-src 'self' cdn.jsdelivr.net ajax.googleapis.com

This is almost always bypassable. Here’s why:

JSONP endpoints on allowlisted domains. JSONP turns an arbitrary callback parameter into a script execution primitive. If any endpoint on ajax.googleapis.com accepts a callback parameter, this executes attacker-controlled code:

<script src="https://ajax.googleapis.com/endpoint?callback=alert(1)"></script>

The domain is allowlisted. The script loads. The callback executes.

User-uploaded content on CDNs. If cdn.jsdelivr.net hosts any file an attacker can upload, they control a script at an allowlisted URL.

'strict-dynamic' solves this. When combined with a nonce, it means: scripts that have a valid nonce (trusted by the developer) may load further scripts dynamically. Scripts without a nonce may not execute — even from allowlisted domains. Domain allowlists are ignored when strict-dynamic is present.

Content-Security-Policy: script-src 'nonce-r4nd0mXYZ' 'strict-dynamic'

Your React or Vite bundle loads via a nonce-bearing <script>. It dynamically imports chunks. Those chunks execute because they were loaded by a trusted script. An attacker’s injected <script> — regardless of what domain it points to — has no nonce and is blocked.

<base> tag hijacking. An injected <base href="https://evil.com/"> rewrites all relative URLs before the script loads. ./bundle.js becomes https://evil.com/bundle.js. Without base-uri 'self' in your policy, this bypasses script-src 'self' entirely. This is why base-uri 'self' is not optional.

CSP Strategy Comparison

Nonce + strict-dynamic is the recommended posture for server-rendered apps. Pure SPAs should use hashes for the initial bundle and strict-dynamic for dynamic imports.
Strategy How it works SPA compatible? Bypass resistance Maintenance
NoncePer-request random token; only matching <script nonce=x> runsYes (requires SSR)HighLow
HashSHA-256 of exact content; fails if content changesYes (static only)HighMedium — rehash on change
strict-dynamicTrusted scripts can load further scripts dynamicallyYesHigh — domain allowlists ignoredLow
Domain allowlistListed CDN origins are allowedYesLow — JSONP and open-redirect bypassesHigh — maintain list

Report-Only Mode

The right way to deploy CSP is not to enforce it immediately. Deploy it in shadow mode first, collect violations, fix what breaks, then enforce.

The Content-Security-Policy-Report-Only header runs the full policy evaluation but does not block anything — it only sends violation reports.

Content-Security-Policy-Report-Only: script-src 'nonce-r4nd0mXYZ' 'strict-dynamic'; report-to csp-endpoint

A violation report looks like this:

{
  "csp-report": {
    "document-uri": "https://devforum.com/articles/123",
    "violated-directive": "script-src-elem",
    "effective-directive": "script-src-elem",
    "blocked-uri": "inline",
    "original-policy": "script-src 'nonce-r4nd0mXYZ' 'strict-dynamic'",
    "disposition": "report"
  }
}

disposition: "report" means the policy is in report-only mode — nothing was blocked, this is just a notification. blocked-uri: "inline" means an inline script without a matching nonce would have been blocked. violated-directive: "script-src-elem" tells you which directive fired. document-uri tells you which page.

Run report-only for at least one full release cycle before switching to enforcement. Aggregate reports by violated-directive and blocked-uri. Fix every inline script that lacks a nonce. Add nonces to third-party script tags. Then flip to enforcement.

CSP Deployment Lifecycle — States and Transitions
State Description Transitions
No CSP No policy header set. All inline scripts execute. No violation data collected. XSS has no second line of defence.
  • Report-Only (add CSP-Report-Only header)
Report-Only Content-Security-Policy-Report-Only active. Policy evaluated but nothing blocked. Violation reports collected for every would-be block.
  • Enforced (violations resolved; switch header to CSP)
  • No CSP (roll back (violation noise too high))
Enforced Content-Security-Policy active. Browser blocks violations before execution. Legitimate scripts must have nonces or hashes.
  • Broken in prod (unresolved violation blocks production script)
Broken in prod Legitimate scripts blocked — users see broken features. Occurs when violations weren't fully resolved before switching to enforcement.
  • Report-Only (revert to report-only; investigate)

CSP Deployment Checklist
Start with Content-Security-Policy-Report-Only

Never enforce a CSP before observing what it would block. Run report-only for a full release cycle, aggregate violations by directive, fix each one, then switch to enforcement.

Never use 'unsafe-inline' in script-src

It disables CSP’s XSS defence completely. Every inline script is allowed — including attacker-injected ones. If inline scripts are breaking, add nonces to them. Do not add ‘unsafe-inline’.

Always set base-uri 'self' and object-src 'none'

These two directives are almost always correct for any modern app. Omitting base-uri leaves the base-tag hijacking vector open. Omitting object-src leaves the plugin execution context open.

Use nonce + strict-dynamic instead of domain allowlists

Domain allowlists have JSONP bypass vectors on most CDNs. Nonces do not. strict-dynamic lets your script loader work without maintaining a CDN domain list.

Generate nonces per-request with crypto.randomBytes

A nonce generated once at server startup and reused across requests is equivalent to a static value. An attacker who observes the nonce once can bypass your policy on every subsequent request.

Honey Sharma

Software engineer focused on web engineering, TypeScript, and distributed systems.