[ Cloudflare + strict CSP — making them coexist ]
If you've ever set a strict Content-Security-Policy
on a Cloudflare-fronted site and watched Bot Fight Mode quietly break it,
this guide is for you. The practical answer is a small Worker that
generates a per-request nonce, splices it into the CSP header, and stamps
the same nonce onto every local inline script. Cloudflare's bot-protection
layer reads the nonced CSP back off the response and applies the nonce to
its own injected scripts. Everything passes policy, nothing requires
'unsafe-inline'.
Tested against magikh0e.pl as of 05.2026 with Bot Fight Mode + JavaScript Detections + Web Analytics all enabled.
[ The problem ]
A "strict" Content-Security-Policy looks something like this:
Content-Security-Policy: default-src 'self';
script-src 'self' https://static.cloudflareinsights.com;
style-src 'self';
...
No 'unsafe-inline', no 'unsafe-eval', just same-origin scripts plus an
allowlist for the Cloudflare Web Analytics beacon. This is the right
default for almost any site. It blocks the most common XSS payloads at
their last line of defence: the browser refuses to execute injected
inline JavaScript even if an attacker managed to plant it in the page.
Then you enable Bot Fight Mode (or JavaScript Detections, or any of the
challenge-based bot products) in the Cloudflare dashboard. Suddenly
every page load throws this in DevTools Console:
Refused to execute inline script because it violates the following
Content Security Policy directive: "script-src 'self'
https://static.cloudflareinsights.com". Either the 'unsafe-inline'
keyword, a hash ('sha256-...'), or a nonce ('nonce-...') is required
to enable inline execution.
Inspecting the HTML shows what Cloudflare's injection layer added: an
inline IIFE that builds a hidden 1x1 iframe, creates a child script
element inside that iframe whose source code embeds a per-request
window.__CF$cv$params object, and then loads the JSD
fingerprinting payload from /cdn-cgi/challenge-platform/scripts/jsd/main.js.
The key detail: the __CF$cv$params={r:'...',t:'...'} object contains
a request ID and a base64-encoded timestamp that are unique per request.
Which means the SHA-256 hash of the script body changes on every page
load — pinning a hash in your CSP is useless.
[ Cloudflare features that inject inline JS ]
Not just JSD. Several Cloudflare features share the same injection
mechanism and trip the same CSP violation:
Feature Dashboard path Notes
---------------------- ------------------------------------ ------------------------------
JavaScript Detections (JSD) Security -> Settings -> Bots -> JS Det. Per-request inline bootstrap
Bot Fight Mode Security -> Bots -> Bot Fight Mode Same JSD bootstrap
Super Bot Fight Mode Security -> Bots -> Super BFM Same, Pro/Biz plans
Managed Challenge WAF custom rule action Triggers JSD on challenged reqs
I'm Under Attack Mode Security -> Settings -> Security Level Every visitor gets JS challenge
Email Obfuscation Scrape Shield -> Email Address Obf. Mailto-related inline script
Rocket Loader Speed -> Optimization -> Rocket Loader Wraps src= scripts in inline JS
AI Crawl Control Security -> Bots -> AI Crawl Control May use JSD for fingerprinting
Web Analytics is the exception — it loads a beacon via
<script src="https://static.cloudflareinsights.com/beacon.min.js">,
which is fine under a strict CSP as long as that host is in your
script-src allowlist. No inline injection.
[ Three approaches (verdicts) ]
Option 1 — 'unsafe-inline' in script-src
One-line .htaccess change. Solves the problem in 30 seconds.
script-src 'self' https://static.cloudflareinsights.com 'unsafe-inline';
VERDICT: Don't. Cloudflare's own docs explicitly say "we highly
discourage the use of 'unsafe-inline'". You lose most of CSP's value
against XSS — any inline script anywhere on the page now runs
freely, attacker-injected or otherwise. The XSS surface might be small
on your specific site, but you're trading away the most important
defence-in-depth layer to dodge a tooling problem that has a clean
solution two options down.
Option 2 — per-request nonce via Worker
A small Cloudflare Worker generates a fresh 128-bit nonce on every HTML response, splices 'nonce-<value>' into the script-src directive of the response's CSP header, and stamps nonce="..." onto every local inline <script> tag via HTMLRewriter. Cloudflare's bot-protection injection layer runs DOWNSTREAM of the Worker. It reads the (now-nonced) CSP off the response header and applies the same nonce to its own injected scripts. End result: every inline script on the page carries a matching nonce, CSP allows them all, no 'unsafe-inline' required. VERDICT: This is the answer. Cloudflare officially supports it and documents it on the JavaScript Detections reference page. ~30 lines of Worker code, one route binding, one .htaccess comment for future-you. Recipe is below.
Option 3 — external JSD via /cdn-cgi/challenge-platform/scripts/jsd/api.js
Cloudflare exposes an explicit external script you can include from
your HTML:
<script defer src="/cdn-cgi/challenge-platform/scripts/jsd/api.js"></script>
Since /cdn-cgi/* is served from your origin (Cloudflare rewrites it
transparently), it's covered by a vanilla script-src 'self' with no
CSP changes needed.
VERDICT: Works for JSD specifically, but NOT for Bot Fight Mode. Bot
Fight Mode and Super Bot Fight Mode always auto-inject — they
have no equivalent external-API toggle. So if your goal is just JSD
without the inline bootstrap, this is the simplest answer. If you also
want Bot Fight Mode protection, you still need Option 2.
[ Nonce Worker recipe ]
Bound to your domain's catch-all route (e.g. yourdomain.com/*).
Reads the origin response, mutates the CSP header, stamps nonces onto
inline scripts, streams the body through HTMLRewriter. No build step,
no npm dependencies — pastes directly into the Cloudflare
dashboard's in-browser editor.
// Cloudflare Worker -- HTML injection at the edge with per-request // CSP nonce. Compatible with Bot Fight Mode, JavaScript Detections, // Super Bot Fight Mode, Managed Challenge, Web Analytics, and any // other Cloudflare feature that respects the response-header CSP nonce. addEventListener('fetch', function(event) { event.respondWith(handle(event.request)); }); // Generate a fresh 128-bit nonce as a base64 string. Single-use; no // padding needed beyond the OWASP minimum because the nonce never // persists past one request. function generateNonce() { var bytes = new Uint8Array(16); crypto.getRandomValues(bytes); var binary = ''; for (var i = 0; i < bytes.length; i++) { binary += String.fromCharCode(bytes[i]); } return btoa(binary); } // Splice 'nonce-<value>' into the script-src directive of a CSP header. // Returns the original if the header is absent or has no script-src -- // don't fabricate directives the origin didn't set. function injectNonceIntoCsp(csp, nonce) { if (!csp) return csp; if (csp.indexOf('script-src') === -1) return csp; return csp.replace( /(script-src[^;]*)/, '$1 \'nonce-' + nonce + '\'' ); } async function handle(request) { if (request.method !== 'GET') return fetch(request); var response = await fetch(request); var ct = response.headers.get('Content-Type') || ''; if (ct.indexOf('text/html') === -1) return response; var cc = response.headers.get('Cache-Control') || ''; if (cc.indexOf('no-transform') !== -1) return response; // Fresh nonce per request, generated AFTER the early returns so we // don't waste entropy on responses we won't modify. var nonce = generateNonce(); // Origin response headers are immutable -- copy, mutate, re-attach. var newHeaders = new Headers(response.headers); var csp = newHeaders.get('Content-Security-Policy'); if (csp) { newHeaders.set('Content-Security-Policy', injectNonceIntoCsp(csp, nonce)); } var noncedResponse = new Response(response.body, { status: response.status, statusText: response.statusText, headers: newHeaders }); // HTMLRewriter: nonce every local inline <script> that isn't a // JSON-LD data block. Skip JSON-LD because it's data, not scripts; // CSP doesn't gate it and stamping a nonce on it is pointless. return new HTMLRewriter() .on('script', { element: function(el) { if (el.getAttribute('src')) return; var type = el.getAttribute('type'); if (type === 'application/ld+json') return; if (el.getAttribute('nonce')) return; el.setAttribute('nonce', nonce); } }) .transform(noncedResponse); }
That's the whole thing. Add any additional rewrites (analytics
beacon injection, banner snippets, A/B test flags) into the same
HTMLRewriter chain as additional .on(...) handlers; the
nonce work stays the same.
[ Deploy & verify ]
Deploy
1. Cloudflare Dashboard -> Workers & Pages -> Create application
-> Create Worker -> name it (e.g. "csp-nonce" or "html-inject")
-> Deploy the placeholder.
2. On the new Worker's overview -> Edit code (top right) -> select
all, delete the placeholder, paste the script above -> Save and
Deploy.
3. Worker -> Domains tab (or Settings -> Triggers depending on
dashboard version) -> Routes -> Add route:
Route: yourdomain.com/*
Zone: yourdomain.com
Save.
4. If you also serve www. or other subdomains, add a route per
subdomain (yourdomain.com/* does not match www.yourdomain.com/*).
Verify
From your terminal:
$ curl -s https://yourdomain.com/ \
| grep -oE 'nonce="[^"]*"|beacon\.min\.js|__CF\$cv\$params' \
| head
Expected output:
nonce="jIYT+c4NeUQF/UVMnf20uQ=="
beacon.min.js # if Web Analytics is on
__CF$cv$params # if Bot Fight Mode / JSD is on
If you see nonce= on the CF-injected __CF$cv$params block (look
inside the <script> tag's outer attribute), Cloudflare's bot layer is
respecting your nonce contract -- the configuration is working
end-to-end.
To inspect the CSP header directly (which is what the
browser enforces), the recipe is:
$ curl -s -D - -o /dev/null "https://yourdomain.com/?cachebust=$RANDOM" \
| grep -i content-security-policy
content-security-policy: default-src 'self'; script-src 'self' \
https://static.cloudflareinsights.com 'nonce-Toil9s1YCnmhVRVCu4DHAg=='; \
...
The four flags matter:
-s silent (no progress bar in the output)
-D - dump response headers to stdout
-o /dev/null discard the body (we only care about headers)
?cachebust= query string defeats edge cache so you see a fresh
Worker run, not a stored response
DO NOT use curl -I (HEAD) for this. The Worker on
this site intentionally skips non-GET methods (the script-injection
work is unnecessary overhead for HEAD/POST/OPTIONS), so HEAD requests
bypass the Worker entirely and return the unmodified origin CSP. If
you debug CSP behaviour with curl -I, the response will look
broken even when the Worker is functioning perfectly on real browser
GETs. This trap cost an hour of debugging once -- don't make the
same mistake.
In a browser:
F12 -> Console tab -- the "Refused to execute inline script..." CSP
violation should be GONE.
F12 -> Network -> click the document row -> Response Headers --
confirm Content-Security-Policy ends with 'nonce-...' on script-src.
A force-refresh (Ctrl+Shift+R) may be needed to clear the browser's
HTTP cache of any pre-deploy response.
[ Common pitfalls ]
The hash you put in your CSP keeps changing
If you tried to add the SHA-256 hash of the offending inline script to
your CSP and discovered it stops working a few minutes later: yes,
that's expected. The __CF$cv$params={r:'...',t:'...'} values are
per-request, so the script body's hash is per-request too. Hashes
are useless here. Use a nonce.
Edge cache serving pre-nonced HTML
Cloudflare's edge cache stores the ORIGIN response (un-nonced) and
relies on the Worker to mutate on every request. This works fine. But
if you previously had a Worker that injected something non-noncified
into the HTML and then changed strategy, old cached HTML may still
contain the un-nonced injection. Purge the Cloudflare cache after
deploying the Worker:
Cloudflare Dashboard -> Caching -> Configuration -> Purge Everything
Service workers in visitors' browsers can also cache HTML. If your site
ships a Service Worker, bump its CACHE_VERSION when this change goes
live so returning visitors re-fetch fresh HTML.
You disabled JSD but the inline script still injects
JSD shares its injection mechanism with Bot Fight Mode, Super Bot Fight Mode, Managed Challenge, and AI Crawl Control. Toggling off just one of them leaves the others firing the same bootstrap. If the CSP violation persists after disabling JSD, check the full features table at the top of this guide and disable each one in turn (or, more practically, just deploy the Worker and re-enable everything).
ERR_NAME_NOT_RESOLVED for beacon.min.js
Different layer entirely -- this is a DNS sinkhole from a local
ad/tracker blocker (Pi-hole, NextDNS, AdGuard Home, browser extension
running on EasyPrivacy/Disconnect blocklists). Resolve the host
manually:
Resolve-DnsName static.cloudflareinsights.com
-- or --
dig static.cloudflareinsights.com
If it comes back as 0.0.0.0 and :: (or NXDOMAIN), your local
resolver is filtering it. The browser dies at DNS resolution before
CSP has a chance to evaluate anything. This is NOT a CSP problem and
not a regression in your site; it's the blocker doing its job. Real
visitors who haven't opted into tracker blocking will see the beacon
fine. Add an allowlist entry if you want your own dev traffic to
register in Web Analytics.
Nonce via <meta> tag doesn't work
Cloudflare specifically does not honor CSP nonces set via <meta http-equiv="Content-Security-Policy" content="...">. The nonce MUST be in the HTTP response header. The Worker recipe above sets the header, so this is handled automatically -- but if you ever consider moving the CSP to a meta tag, don't. You lose the nonce contract with Cloudflare and your bot-protection scripts go back to being blocked.
Local inline scripts already on your page
The Worker stamps a nonce on every <script> that has no src= and isn't type="application/ld+json". JSON-LD blocks (Schema.org structured data) are intentionally skipped -- they're data, not executable. If you have other non-executable script types you use for templating or data embedding (e.g. type="text/template"), they'll get a harmless nonce attribute that browsers ignore. No functional change.
