Content Security Policy: Building a Strict, Nonce-Based Policy

Content Security Policy: Building a Strict, Nonce-Based Policy - article cover image Web Exploitation
Time it takes to read this article 6 minutes.

Disclaimer: This article is provided strictly for educational purposes and authorized security testing. Only run these techniques against systems you own or have explicit written permission to assess. Unauthorized access to computer systems is illegal in virtually every jurisdiction and can carry severe penalties.

Introduction

Content Security Policy (CSP) is a response header that tells the browser which sources of script, style, and other content a page is allowed to load and execute, acting as defense-in-depth against XSS: even if an attacker manages to inject markup, a correctly built CSP can stop the injected script from actually running. The catch is in “correctly built” — the traditional approach of listing trusted *hosts* (script-src 'self' https://cdn.example) has been shown repeatedly, at scale, to be bypassable, because almost every large allowlisted host also serves *something* an attacker can abuse as a script-execution gadget.

The modern alternative is a strict, nonce-based policy: instead of trusting *where* a script comes from, the server issues a fresh, unpredictable random value (a nonce) with every response and only executes <script> tags carrying that exact value. Combined with strict-dynamic, object-src 'none', and base-uri 'none', this model — pioneered and measured at scale by Google — closes off the host-allowlist bypass gadgets entirely, because trust no longer flows from an origin an attacker might also reach, only from a value they cannot predict.

Attack Prerequisites

This article is about building a strong policy, but understanding what makes the weak version breakable requires the same prerequisites an attacker needs to exploit it:

  • An HTML/script injection point the CSP is meant to neutralize (a stored or reflected XSS sink) — CSP is only relevant once such an injection already exists.
  • A policy that trusts either 'unsafe-inline' or a broad set of hosts for script-src, so the injected markup has *some* CSP-permitted way to execute.
  • For host-allowlist bypasses specifically: a JSONP endpoint, AngularJS/legacy framework callback, or an old, vulnerable library version hosted on one of the allowlisted origins that can be coerced into executing attacker-controlled script despite being “trusted”.

How It Works

A policy such as script-src 'self' https://cdn.jsdelivr.net https://www.google.com looks reasonable — it names first-party plus two well-known origins — but each additional trusted host is also an additional attack surface the policy silently inherits. Large origins like a CDN or google.com serve enormous, constantly changing sets of endpoints; if *any* of them can be made to reflect or execute attacker-supplied JavaScript (an open JSONP callback, an AngularJS app whose template-injection sandbox can be escaped, an old jQuery version with a known gadget), the CSP does nothing to stop it, because the browser only checks the *origin*, not what that origin is actually serving at the moment of the attack. Google’s own large-scale study of deployed CSPs found the overwhelming majority of host-allowlist policies bypassable for exactly this reason.

The nonce-based model flips what CSP trusts. The server generates a fresh, cryptographically random value on every single response and emits it in two places that must match: the CSP header (script-src 'nonce-r4nd0m...') and the nonce attribute of every legitimate <script> tag in that response’s HTML. An injected <script> tag — however it got there — will not know the nonce for that response (a fresh one is generated per request, never reused, never guessable), so the browser refuses to execute it, regardless of what host it would have loaded from or whether it’s inline.

'strict-dynamic' solves the practical problem this creates for legitimate script loaders: a nonce-bearing script is allowed to dynamically insert further <script> tags (common in bundlers and tag-manager setups) without those child scripts needing their own nonce, while host-based allowlist entries are explicitly ignored by browsers that understand strict-dynamic — trust propagates from the nonce, not from a domain. Because older browsers ignore strict-dynamic entirely, the recommended pattern pairs it with a fallback host list (commonly just https:) that modern browsers ignore in its presence but legacy browsers still honor, preserving compatibility without reopening the gap for browsers that support the stricter model.

Vulnerable Code / Configuration

A common, deceptively reasonable-looking weak policy — the kind that shows up in real production headers — undermines itself with one directive value and one missing pair of restrictions:

Content-Security-Policy: default-src 'self'; script-src 'self' \
  https://cdn.jsdelivr.net https://www.google.com 'unsafe-inline'; \
  object-src 'self'

# Problems:
#  - 'unsafe-inline' allows ANY injected <script> or inline event handler to
#    run, which defeats the policy's entire purpose against XSS on its own.
#  - https://www.google.com is a huge origin serving many endpoints; some
#    have historically been usable as open-redirect / JSONP-style script gadgets.
#  - no base-uri restriction: a <base href="https://attacker.example/">
#    injection can redirect the resolution of any relative script src.
HTTP

A second, subtler bug undermines a nonce-based policy that otherwise looks correct: generating the nonce once at build/deploy time and reusing it for every response, instead of generating it fresh per request:

// VULNERABLE: static nonce baked in once, identical on every response
const NONCE = 'app-static-nonce-123';   // never regenerated

app.get('/', (req, res) => {
  res.setHeader('Content-Security-Policy',
    `script-src 'nonce-${NONCE}' 'strict-dynamic'; object-src 'none'; base-uri 'none';`);
  res.send(renderPage({ nonce: NONCE }));
});
// Any GET request reveals the nonce in the page source, so an attacker's
// injected <script nonce="app-static-nonce-123"> now executes too.
JavaScript

Walkthrough / Building the Policy

Inventory every script source first: crawl the application and record every <script src>, inline <script> block, and on* event-handler attribute in the rendered HTML, plus any use of eval/new Function/setTimeout(string).

Refactor what you find: move inline event handlers to addEventListener calls in an external script, and eliminate string-based eval/Function usage — these cannot be reconciled with a strict nonce policy and have no 'unsafe-*' escape hatch worth using.

Generate a fresh, high-entropy nonce per response server-side and thread it into both the header and every legitimate script tag’s template:

const crypto = require('crypto');

app.use((req, res, next) => {
  res.locals.nonce = crypto.randomBytes(16).toString('base64');  // fresh per request
  res.setHeader('Content-Security-Policy', [
    `script-src 'nonce-${res.locals.nonce}' 'strict-dynamic' https:`,
    "object-src 'none'",
    "base-uri 'none'",
    "require-trusted-types-for 'script'",
    "report-to csp-endpoint",
  ].join('; '));
  next();
});
// Template: <script nonce="<%= nonce %>" src="/app.js"></script>
JavaScript

Roll out with Content-Security-Policy-Report-Only first, pointed at a report-to/report-uri collector, and monitor violation reports for a representative period to catch legitimate functionality the new policy would break before switching it to enforcing mode.

Validate the final policy with Google’s free CSP Evaluator (csp-evaluator.withgoogle.com), which flags 'unsafe-inline', overly broad hosts, and missing object-src/base-uri restrictions automatically, and re-run it after every policy change — Burp’s passive scanner also flags several of the same weak-CSP patterns during normal crawling.

Note: 'strict-dynamic' is ignored by browsers that don’t support it, which is exactly why it’s paired with a fallback like https: in the same directive — modern, strict-dynamic-aware browsers ignore the fallback entirely in its presence, while older browsers fall back to the (weaker, but still useful) host-based restriction. Don’t drop the fallback thinking it’s dead weight; it’s load-bearing for the browsers that need it.

Opsec / Testing tip: To prove a deployed CSP is weak, try a classic host-allowlist gadget injection (a JSONP-style <script src="https://allowlisted-host/callback?cb=alert(1)">) or a <base href="https://attacker.example/"> injection if base-uri is unset. To prove a nonce-based policy is broken by reuse, request the page twice from different sessions and diff the nonce value — if it’s identical, the policy provides no real protection.

Detection and Defense

A strict policy is built from a small set of non-negotiable directive choices:

  • Generate a new, cryptographically random nonce (≥128 bits) on every single response — never cache, hardcode, or reuse one across requests.
  • Never include 'unsafe-inline' or 'unsafe-eval' in a nonce-based policy — either defeats it entirely for any script/handler an attacker can inject.
  • Set object-src 'none' to remove legacy plugin-based execution vectors, and base-uri 'none' to prevent <base>-tag injection from redirecting relative resource resolution.
  • Prefer strict-dynamic over long host allowlists so trust flows from the nonce rather than from origins that may host attacker-reachable gadgets.
  • Adopt require-trusted-types-for 'script' where feasible to close off DOM-sink-based script injection that a script/style policy alone doesn’t address.
  • Monitor violations via report-to in production, and treat unexpected reports as a signal of either an injection attempt or a legitimate feature the policy needs to accommodate correctly.

Real-World Impact

Google’s CCS 2016 study “CSP Is Dead, Long Live CSP!” (Weichselbaum et al.) analyzed CSP deployments at internet scale and found that the vast majority of host-allowlist-based policies were trivially bypassable via gadgets hosted on the very origins they trusted — the finding that directly motivated the strict-dynamic/nonce model now recommended by Google and deployed on products including Gmail. It remains the most effective, best-studied version of CSP available today for actually stopping injected script from executing.

Conclusion

A CSP is only as strong as what it trusts, and trusting *origins* instead of *unpredictable per-response values* has been shown, repeatedly and at scale, to be the weaker choice. Generate a fresh nonce on every response, pair it with strict-dynamic, object-src 'none', and base-uri 'none', eliminate 'unsafe-inline'/'unsafe-eval' entirely, and validate the result with CSP Evaluator before and after every change — that combination is what turns CSP from a checkbox header into a policy that actually stops injected script from running.

You Might Also Like

If you found this useful, these related deep-dives cover adjacent techniques and their defenses:

Comments

Copied title and URL