Cloudflare CSP Nonces with Workers
This guide is part of the Cloudflare Security Headers at the Edge reference and shows how to mint a fresh, unguessable Content-Security-Policy nonce for every request inside a Cloudflare Worker, then use HTMLRewriter to stamp that same nonce onto every inline <script> tag and into the Content-Security-Policy response header in a single streaming pass. A nonce is only sound when it is unpredictable and used exactly once per response, so the value must be generated at the edge on the response path — a static rule engine cannot do this. That constraint also dictates the cache strategy: noncing HTML must never be served from cache, because a cached nonce is a reused nonce, and a reused nonce is no nonce at all.
Configuration Syntax & Exact Values
The nonce is a base64 string derived from 16 cryptographically random bytes. The same token appears in two places, and they must match byte-for-byte:
Content-Security-Policy: default-src 'self'; script-src 'nonce-r6Yy1Q8kZ2bF7pN0wJxLg==' 'strict-dynamic'; object-src 'none'; base-uri 'none'
<script nonce="r6Yy1Q8kZ2bF7pN0wJxLg==">/* trusted inline code */</script>
Directive breakdown for the script-src value:
'nonce-' Allows exactly the inline/loaded scripts carrying this attribute.
The token is fresh per response; an attacker cannot predict it.
'strict-dynamic' Scripts trusted by the nonce may load further scripts.
Makes host allowlists (and their bypasses) irrelevant to modern browsers.
object-src 'none' Blocks
Three rules make or break the value:
- The nonce MUST be at least 128 bits of entropy.
crypto.getRandomValues(new Uint8Array(16))yields exactly 16 bytes (128 bits); base64-encode the raw bytes, do not hex-truncate a UUID. - The header token and the HTML attribute MUST be identical. Generate the nonce once per request and thread the single string through both writes.
- The nonce MUST NOT be quoted in the HTML attribute but MUST be wrapped in
'nonce-...'inside the header.nonce="abc"in HTML,'nonce-abc'in the CSP.
Server-Side Configuration
The Worker fetches the origin HTML, generates one nonce, rewrites inline scripts through HTMLRewriter, sets the CSP header from the same token, and forces the noncing HTML to bypass cache. This is the equivalent of Nginx sub_filter noncing but running at every Cloudflare edge location.
Complete Worker: nonce generation, HTMLRewriter injection, CSP header
// wrangler.jsonc route binds this Worker to your zone's HTML paths.
function generateNonce() {
const bytes = new Uint8Array(16); // 128 bits of entropy.
crypto.getRandomValues(bytes); // Cryptographic RNG, not Math.random.
return btoa(String.fromCharCode(...bytes)); // Raw-byte base64, e.g. "r6Yy1Q8kZ2bF7pN0wJxLg==".
}
class NonceInjector {
constructor(nonce) {
this.nonce = nonce;
}
// Runs for every <script> element in the streamed HTML.
element(el) {
// Only stamp inline scripts; leave external src=... scripts to 'strict-dynamic'.
if (!el.hasAttribute("src")) {
el.setAttribute("nonce", this.nonce);
}
}
}
export default {
async fetch(request, env, ctx) {
const nonce = generateNonce();
// cf.cacheEverything:false ensures noncing HTML is never served from cache;
// a cached nonce is a reused nonce and silently breaks the guarantee.
const originResponse = await fetch(request, {
cf: { cacheEverything: false, cacheTtl: 0 },
});
const contentType = originResponse.headers.get("content-type") || "";
// Only rewrite HTML. Pass images, JSON, assets straight through untouched.
if (!contentType.includes("text/html")) {
return originResponse;
}
const csp = [
"default-src 'self'",
`script-src 'nonce-${nonce}' 'strict-dynamic'`,
"object-src 'none'",
"base-uri 'none'",
].join("; ");
// new Response() gives mutable headers; fetch() headers are immutable.
const response = new Response(originResponse.body, originResponse);
response.headers.set("Content-Security-Policy", csp);
// Belt-and-braces: mark the HTML itself uncacheable downstream.
response.headers.set("Cache-Control", "private, no-store");
return new HTMLRewriter()
.on("script", new NonceInjector(nonce))
.transform(response);
},
};
wrangler.jsonc route binding
{
"name": "csp-nonce-worker",
"main": "src/index.js",
"compatibility_date": "2026-01-01",
"routes": [
{ "pattern": "example.com/*", "zone_name": "example.com" }
]
}
Report-only staging variant
Swap the header name during rollout so violations are reported without blocking execution. Everything else is identical:
// Replace the single set() call above with this during staging.
response.headers.set(
"Content-Security-Policy-Report-Only",
csp + "; report-uri /csp-report",
);
Content-Security-Policy-Report-Only never blocks; it only emits reports. Ship this first, watch the report endpoint, then rename to the enforcing header.
Diagnostic & Verification Steps
Confirm the header token matches the HTML attribute and that the value changes on every request. First, read the header:
curl -sI https://example.com/ | grep -i content-security-policy
Expected output (the token differs each run):
content-security-policy: default-src 'self'; script-src 'nonce-r6Yy1Q8kZ2bF7pN0wJxLg==' 'strict-dynamic'; object-src 'none'; base-uri 'none'
Prove the nonce is per-request, not cached. Two calls must return two different tokens:
for i in 1 2; do
curl -sI https://example.com/ | grep -oiE "nonce-[A-Za-z0-9+/=]+"
done
Expected output — two distinct values:
nonce-r6Yy1Q8kZ2bF7pN0wJxLg==
nonce-9dTt4M2vXp1cH5aK7uQwEg==
If both lines are identical, the HTML is being cached; revisit the cf.cacheEverything and Cache-Control settings below. Now confirm the same token reached the HTML body and that the header and body agree:
URL=https://example.com/
HDR=$(curl -s -D - "$URL" -o body.html | grep -oiE "nonce-[A-Za-z0-9+/=]+" | sed 's/nonce-//')
BODY=$(grep -oE 'nonce="[A-Za-z0-9+/=]+"' body.html | head -1 | sed 's/nonce="//;s/"//')
echo "header=$HDR body=$BODY"
Expected output — the two strings are equal:
header=r6Yy1Q8kZ2bF7pN0wJxLg== body=r6Yy1Q8kZ2bF7pN0wJxLg==
In Chrome DevTools, open the Console after loading the page. A working policy shows no CSP errors. A mismatch surfaces as:
Refused to execute inline script because it violates the following Content Security Policy directive:
"script-src 'nonce-...' 'strict-dynamic'". Either the 'unsafe-inline' keyword, a hash, or a nonce is required.
That message means an inline <script> reached the browser without the matching nonce attribute — usually a script injected downstream of the Worker (see edge cases). The wrangler tail stream confirms the Worker executed on the request path:
npx wrangler tail csp-nonce-worker --format pretty
Edge Cases, Security Implications & Safe Rollback
Three traps are specific to noncing at the Cloudflare edge:
-
Cached HTML reuses the nonce. If the noncing HTML is cacheable — via a Cache Rule,
cacheEverything: true, or an originCache-Control: publicthat Cloudflare honours — every visitor receives the same stale token. An attacker who reads one page then knows the nonce for all cached hits, defeating the mechanism entirely. Noncing HTML MUST be uncacheable: keepcf.cacheEverything: falseand setCache-Control: private, no-storeas shown. Cache static assets aggressively; never cache the noncing document. See Cache-Control and Clear-Site-Data for the caching model. -
Scripts added after the Worker are unnoticed.
HTMLRewriteronly stamps scripts present in the origin HTML it streams. Cloudflare features that inject their own inline scripts after the Worker runs — Rocket Loader, Email Address Obfuscation, Automatic HTTPS Rewrites, Web Analytics — add unnonced<script>tags that a strict policy then blocks. Disable Rocket Loader and Email Obfuscation on noncing routes, or add their documented static nonces.'strict-dynamic'covers scripts that trusted code loads programmatically, but not tags injected into the raw markup by a later proxy stage. -
Header and body drift on any early return. Every code path that returns HTML must carry both the injected attribute and the matching header. An early
return originResponsefor an error page, a redirect, or a non-HTML branch that accidentally passes HTML will ship a body with no nonce and a header demanding one — a hard block. Generate the nonce once at the top and guard every HTML return through the same rewriter.
Rollback is non-destructive and instant. The Worker sits in front of the origin; removing it restores the origin’s own headers with zero data loss. To roll back in order of blast radius: first switch the header name to Content-Security-Policy-Report-Only (violations are reported, nothing is blocked); if problems persist, delete the route binding in wrangler.jsonc and redeploy, or disable the route in the dashboard. The origin HTML is never modified at rest, so no cleanup is required.
Conclusion
Deploy to a staging route first with Content-Security-Policy-Report-Only, watch the report endpoint until inline-script violations stop, then rename to the enforcing Content-Security-Policy header on that route. Promote to production one route at a time, confirming with the two-request curl check that every response carries a fresh, matching nonce and that the noncing HTML is never cached. Once production is quiet, tighten script-src to 'nonce-... ' 'strict-dynamic' only and remove any transitional 'unsafe-inline' fallback.
Frequently Asked Questions
Why use crypto.getRandomValues instead of crypto.randomUUID for the nonce?
crypto.randomUUID returns a 36-character string but only ~122 bits are random, and its fixed hyphen layout and version nibble are predictable structure. crypto.getRandomValues(new Uint8Array(16)) gives a clean 128 bits of entropy that you base64-encode directly. Both run in the Workers runtime, but the raw-bytes approach is the spec-recommended shape for a nonce.
Can I generate the nonce with a Cloudflare Transform Rule or Page Rule instead?
No. Transform Rules and Page Rules apply static values and cannot execute per-request code or read the response body. A nonce must be freshly random each response and written into the HTML body, which requires HTMLRewriter inside a Worker. Static rule engines can set fixed security headers but not nonces.
Does 'strict-dynamic' mean I do not need to nonce every inline script?
Only inline scripts you author still need the nonce attribute to be trusted initially. 'strict-dynamic' extends that trust to scripts those trusted scripts load at runtime, so you do not maintain host allowlists — but the browser will still block any inline <script> in the raw HTML that lacks the matching nonce.
Why must the noncing HTML bypass the Cloudflare cache?
Caching stores the response body — including the baked-in nonce — and replays it to other visitors. Every cache hit then serves the same token, so the nonce is reused across users and is trivially known to anyone who fetched the page. Setting cf.cacheEverything: false and Cache-Control: no-store on the HTML keeps each nonce single-use while your static assets stay fully cacheable.
My inline scripts are still blocked even though the header looks correct — why?
The token in the Content-Security-Policy header does not match the nonce attribute in the HTML, or a script was injected after the Worker ran. Compare both tokens with the curl diagnostic above, and disable Cloudflare features such as Rocket Loader and Email Obfuscation on the route, since they add unnonced inline scripts downstream of the Worker.