p4ni.

Comparison

CSP Nonce vs Hash on a Static Site: The Edge Workaround Stamps the Attacker's Script Too

· 8 min read

On this page

Every “nonce vs hash” article ends the same way. Hashes are brittle — a stray space breaks them, Prettier breaks them, your CI breaks them — so use nonces. Static sites can’t mint a nonce, but that’s fine: put a Cloudflare Worker in front, rewrite the HTML per request, and you get nonces without touching your app.

I ship a hash-based CSP on Cloudflare Workers, so this is the argument that I picked wrong. Before writing it off I built the Worker version and pointed a browser at it. It works exactly as advertised, and that turns out to be the problem: the rewriter signs every inline script in the response, and it has no way to know which ones you wrote.

Why a static site can’t mint one

A nonce is a random value the server picks per response, stamps on every legitimate <script>, and lists in the CSP header. An injected script can’t guess it, so it doesn’t run.

Every clause there needs code running per request. My dist/ is uploaded to Cloudflare Workers static assets and served as files — no request-time hook to stamp anything. Baking a nonce in at build time makes it a constant, and a constant nonce is worse than none: a permanent allowlist entry that any injected markup can copy.

Hashes need no server. You SHA-256 each inline script’s contents, list the digests in script-src, and the browser compares what it finds against the list.

The workaround everyone recommends

Cloudflare’s HTMLRewriter streams through a response and edits elements as they go past. Thirty lines gets you the whole pattern:

// worker.mjs
class Stamp {
  constructor(nonce) {
    this.nonce = nonce;
  }
  element(el) {
    el.setAttribute('nonce', this.nonce);
  }
}

export default {
  async fetch(request, env) {
    const bytes = new Uint8Array(16);
    crypto.getRandomValues(bytes);
    const nonce = btoa(String.fromCharCode(...bytes));

    const res = await env.ASSETS.fetch(request);
    const headers = new Headers(res.headers);
    headers.set('content-security-policy', `default-src 'self'; script-src 'nonce-${nonce}'`);

    return new HTMLRewriter()
      .on('script', new Stamp(nonce))
      .transform(new Response(res.body, { status: res.status, headers }));
  },
};

To test it I served a fixture instead of hitting the asset store — three scripts, one of which stands in for markup that got into the HTML without my approval:

<script>document.title = 'legit script ran'</script>
<!-- pretend an XSS put this in the origin HTML -->
<script>window.__pwned = true</script>
<script src="/app.js"></script>

wrangler dev, then curl:

content-security-policy: default-src 'self'; script-src 'nonce-ePcmaRi4noW+NAv9riNctA=='

<script nonce="ePcmaRi4noW+NAv9riNctA==">document.title = 'legit script ran'</script>
<!-- pretend an XSS put this in the origin HTML -->
<script nonce="ePcmaRi4noW+NAv9riNctA==">window.__pwned = true</script>
<script src="/app.js" nonce="ePcmaRi4noW+NAv9riNctA=="></script>

The value rotates on every request, so the mechanism is working. Three consecutive fetches gave yK/2mSLm2LTjw2IG5VN1BA==, abmttQqQAZGNDMFO05QjzA== and z6k0MIWwFTVKMXCl65cQZA==. It is a real nonce by every definition the spec offers.

Loading it in Chrome tells you what that bought:

{
  "title": "legit script ran",
  "pwned": true
}

Both scripts ran. The policy has no 'unsafe-inline', no hashes, and nothing but a nonce in script-src — and the injected script executed anyway, because the Worker put a valid nonce on it on the way out.

A nonce is a provenance claim, not a random number

The randomness was never the point. A nonce works because the code that stamps it knows which scripts are legitimate — it’s your template engine, marking the tags it wrote itself. The value being unguessable only matters on top of that.

An edge rewriter matching script has no such knowledge. By the time the HTML reaches the Worker, your script and the attacker’s are the same thing: a <script> element in a byte stream. Selecting script and calling setAttribute is a promise you can’t keep — an automated 'unsafe-inline' with extra latency.

That’s not to say it does nothing. Scripts injected after load through document.createElement and appendChild never get a nonce, so those stay blocked — the same DOM-based XSS a hash policy blocks. What the edge nonce loses is precisely the case that hashes cover: untrusted content that made it into the HTML itself. On a blog built from MDX — a format that passes raw HTML through by design — that’s not a hypothetical vector. It’s the one that matters.

One thing to know when you go looking: reading the attribute back gives you nothing.

script.getAttribute('nonce'); // ""
script.nonce;                 // the actual value

That’s nonce hiding, and it’s deliberate. MDN’s example is a CSS attribute selector — script[nonce~="whatever"] { background: url(...) } — that would otherwise exfiltrate the value character by character. The browser clears the content attribute and keeps the IDL property. Useful to know before you conclude your rewriter didn’t fire.

What the workaround costs when you accept it anyway

Say you want the edge nonce regardless, for the DOM-injection case or to satisfy a scanner. It isn’t free.

Every HTML request becomes a billed invocation. Cloudflare’s docs are unambiguous: “Requests to static assets are free and unlimited,” while “Requests to the Worker script (for example, in the case of SSR content) are billed according to Workers pricing.” Rewriting HTML means your Worker runs first, so the free-and-unlimited path is the one you just opted out of.

The fallback disappears with it. Under run_worker_first, requests matching those patterns “will always invoke your Worker script. If you exceed your free tier request limits, these requests will receive a 429 (Too Many Requests) response instead of falling back to static asset serving.” A traffic spike on a static blog is normally the cheapest thing that can happen to you. Route it through a Worker and the same spike serves 429s.

A per-request value is an uncacheable response. Two visitors can’t share HTML whose nonce differs by definition. You keep asset caching for CSS, JS and images, and you give up full-response caching on every document.

For a site whose HTML is identical for every visitor, that’s three real costs against a security property I just measured as absent.

What Astro decided, and the catch in it

Astro added CSP support in 5.9, and picked hashes. The release post is direct about why: “Using the Response header wouldn’t work for Astro, because it would leave out static websites and SPAs. For this reason, we decided to use the <meta> element to provide the CSP to the browser.” Hashes weren’t chosen because they’re elegant — they were chosen because they’re the option that survives having no server.

The <meta> decision has a consequence worth knowing before you rely on it. A policy delivered in a meta element silently drops some directives. I put four in one:

<meta http-equiv="content-security-policy"
      content="default-src 'self'; frame-ancestors 'none'; sandbox; report-uri /csp-report">

Then framed that page from another page on the same origin. frame-ancestors 'none' should block it outright:

{ "framedTitle": "meta csp", "framedBody": "ok", "err": null }

The frame loaded, and same-origin script could read straight into its document — so sandbox was ignored too. frame-ancestors is your clickjacking defense and report-uri is how violations reach you instead of a stranger’s devtools; in a meta tag, neither exists. Both need a real header.

That’s why I generate _headers at build time rather than turning on the built-in feature. Astro’s hashes cover the scripts it bundles; the header is the only place the rest of a policy can live.

When a nonce is actually the answer

Hashes stop being viable the moment your inline scripts stop being constant. If a script tag carries a per-user CSRF token, a session ID, or anything else rendered from request state, its digest changes per response and there’s nothing to put in the policy. That’s an SSR problem with an SSR solution, and if you’re already rendering per request, the nonce is stamped by the code that knows what it wrote. It’s the real thing rather than the edge imitation.

The brittleness argument against hashes also assumes you’re maintaining them by hand. Generating them in astro:build:done removes it: reformat an inline script and the next build emits a different digest without anyone noticing. The whitespace sensitivity is real, and it’s only a maintenance burden if a human is in the loop.

So the rule is about where your HTML comes from, not which mechanism sounds stronger:

Your HTMLUse
Prerendered, identical for every visitorHashes, generated at build time
Rendered per request with request-specific data inlineNonces, from the renderer
Prerendered, but a scanner wants to see 'nonce-'Understand that you’re buying the report, not the property

There is a second case I ran into later, and it doesn’t fit that table cleanly: a CDN that injects its own inline script into your response. Cloudflare’s Bot Fight Mode does exactly that, with a payload carrying a per-request ray ID, so no static hash can ever cover it — and Cloudflare will read your policy and stamp your nonce onto its script if you have one. That is a real argument for nonces on a prerendered site. It also walks straight into the objection above: the Worker you’d stand up to mint that nonce is the same rewriter that would sign anything else inline, including a script that shouldn’t be there.

Recap

  • Nonces work because the stamper knows which scripts are legitimate. An edge rewriter matching script doesn’t, so it signs injected inline scripts along with yours — I measured this, and the injected script ran under a nonce-only policy.
  • Edge nonces still block scripts appended to the DOM after load. They don’t block untrusted content that reached the HTML, which is the case hashes exist for.
  • Fronting static assets with a Worker turns free unlimited requests into billed invocations, replaces overage fallback with 429s, and makes every document uncacheable.
  • Astro chose hashes because a Response header “would leave out static websites and SPAs” — and ships the policy in a <meta>, where frame-ancestors, sandbox and report-uri are silently dropped. Write a real header for those.
  • Hash brittleness is a symptom of hand-maintenance. Generate the digests in your build and it goes away.