Research
Cloudflare Injects Two Scripts Into Your HTML. My CSP Blocked One.
· 8 min read
On this page
A Tell HN two days ago, now past 320 points: someone switched their nameservers to Cloudflare to serve an R2 bucket from a subdomain, and found a JavaScript analytics snippet in the HTML of their JS-free site. Most of the thread is people checking their own sites in real time and getting different answers. One person sees the beacon, another with the same setup doesn’t, a third has it on some domains but not others.
That disagreement is the interesting part. I run two zones with a mix of Pages projects and Workers static assets, so I measured all of them.
Two different scripts get injected, and the beacon arrives by either of two routes — which is enough to account for the conflicting reports from everyone in that thread whose site is proxied. One of the two is also being blocked by my own Content Security Policy, which means a Cloudflare security feature I never chose has been dead in the browser since the day I shipped that policy.
What eight hosts actually return
Every row is curl against production, checking for static.cloudflareinsights.com/beacon.min.js
and for an inline script bootstrapping /cdn-cgi/challenge-platform/scripts/jsd/:
| Host | Served by | CSP | Beacon | JS Detections |
|---|---|---|---|---|
p4ni.com | Pages, custom domain | none | yes | yes |
p4ni-2li.pages.dev | Pages, pages.dev | none | yes | no |
ui.p4ni.com | Pages, custom domain | none | no | yes |
stats.p4ni.com | Pages, custom domain | none | no | yes |
ppaby.com | Pages, custom domain | yes | yes | no |
yaso.ppaby.com | Pages, custom domain | none | yes | no |
astro.p4ni.com | Workers static assets | yes | no | yes |
almanac.p4ni.com | Workers static assets | none | no | yes |
Read the columns separately and the pattern falls out immediately. The beacon column tracks the
Pages project. The detections column tracks the zone: everything on p4ni.com gets it,
nothing on ppaby.com does, and pages.dev — which is in neither zone — gets it nowhere.
Neither column tracks “is this proxied through Cloudflare.” Every host here is served by Cloudflare.
The CSP column has one thing to say in advance. ppaby.com runs a policy and carries the
beacon, because that policy names static.cloudflareinsights.com in its script-src. An
external script is a permissions question and you can answer it in your allowlist. Hold that
thought for the last section, where the other injection turns out not to be that kind of
problem at all.
The beacon, route one: Pages injects it itself
The beacon is a single deferred script tag with an account token:
<script defer src='https://static.cloudflareinsights.com/beacon.min.js'
data-cf-beacon='{"token": "b4ed0d7866324798b84e6ad1913b67b0"}'></script>
On all four hosts where I found it, it arrives wrapped in an HTML comment that names its source:
<!-- Cloudflare Pages Analytics --><script defer src='...'></script><!-- Cloudflare Pages Analytics -->
That comment is the tell. Pages injects the beacon itself at serve time, independent of the
proxy — which is why p4ni-2li.pages.dev has it even though pages.dev is outside my zone,
and why ui.p4ni.com doesn’t have it even though it sits in the same zone as sites that do.
For Pages it is per-project and opt-in: Workers & Pages → your project → Metrics → Enable.
Three of the five projects I measured have it on because I turned it on and forgot.
The beacon, route two: on by default for free zones since October 2025
The Tell HN case is a different path to the same script, and it’s the one I could not reproduce. Cloudflare announced in September 2025 that from October 15 it would enable Web Analytics by default on free zones, injecting the beacon into proxied HTML at the edge. Paid plans still opt in.
So a free domain that gets orange-clouded now ships an analytics script the owner never asked for, and the toggle that turns it off lives under Web Analytics rather than anywhere near the DNS screen you were just on. Cloudflare’s commitment is that disabling it sticks: “Once you have disabled the product once, we will not re-enable it again.”
I should be clear that I never observed this route. All four beacons on my account came from Pages and carried the comment wrapper, so if the edge-injected version looks different in the markup, I can’t tell you how. If you find a beacon with no comment around it, this is the likely source.
Two conditions stop the edge injection, per the
FAQ — the response must be valid HTML,
and Cache-Control: public, no-transform blocks it, because the proxy won’t modify a payload
marked no-transform. That is the one lever here you can pull from your own codebase rather
than a dashboard. On Workers static assets it belongs in your _headers
file. I did not test it against the Pages path, and
I’d expect it not to help there: Pages does its own injection rather than going through the
proxy rewriter.
Neither of my Workers static assets sites carries a beacon, on a zone where Pages sites do. I want to be careful about what that proves. Since I never saw the zone-level route on either zone, I can’t distinguish “Workers asset responses are exempt from the edge rewriter” from “the edge rewriter was never active here in the first place.” What I can say is that the Pages injection definitely doesn’t reach them, and that if you want RUM on Workers you’ll be adding the script yourself — one more entry for the Pages versus Workers ledger, and one that isn’t in either product’s docs.
JavaScript Detections, which you cannot turn off
This is the injection nobody in the thread mentioned, and it goes deeper into your markup than
the beacon does. There’s no src to point at: the code itself is written into your HTML. The
outer wrapper builds a hidden 1×1 iframe and then writes a second script into that iframe’s
document:
(function(){
function c(){
var b=a.contentDocument||(a.contentWindow&&a.contentWindow.document);
if(b){var d=b.createElement('script');
d.innerHTML="window.__CF$cv$params={r:'a2c597d03c31c0b4',t:'MTc4NjkzNzM1MQ=='};"+
"var a=document.createElement('script');"+
"a.src='/cdn-cgi/challenge-platform/scripts/jsd/main.js';…";
b.getElementsByTagName('head')[0].appendChild(d)}}
if(document.body){var a=document.createElement('iframe');
a.height=1;a.width=1;a.style.visibility='hidden';document.body.appendChild(a); … }
})();
It comes from JavaScript
Detections,
part of Bot Fight Mode, and it is per-zone: on for p4ni.com, off for ppaby.com, absent on
pages.dev. Unlike the beacon it does land on Workers static assets — the responses my
Worker serves come back with it appended.
On Bot Fight Mode — the free tier — JavaScript Detections is “automatically enabled and cannot be disabled.” There’s no toggle. Turning it off means turning off Bot Fight Mode for the zone. Super Bot Fight Mode and Enterprise get a real switch.
A hash-based CSP can never allow this script
astro.p4ni.com ships a policy with no 'unsafe-inline', built from hashes of its own inline
scripts:
script-src 'self' 'sha256-0at8MBhV/…' 'sha256-QGOI0zA5LP…' … https://www.googletagmanager.com
Cloudflare appends its wrapper after that header is set. The browser does what it was told:
Executing inline script violates the following Content Security Policy directive
'script-src 'self' 'sha256-…' …'. Either the 'unsafe-inline' keyword, a hash
('sha256-BzqoJdU21HVAYQREq/sA3S0K+mfmYqOMdKl9oOK6IH0='), or a nonce is required
to enable inline execution. The action has been blocked.
Chrome helpfully offers the hash. It is useless. Look again at what the wrapper carries: r:
is the CF-Ray of that specific request and t: is a timestamp. Three consecutive requests to
the same URL:
ray=a2c59c2f2d92e07a ts=MTc4NjkzNzUzMA== sha256-PzYAl89jKGJxYnK3RBTdOBwEoRJ6IjqugAiWRQ/HO9c=
ray=a2c59c3098d0e05a ts=MTc4NjkzNzUzMA== sha256-pm21k32xDqdTnabwlxqMt+ptI9f+CyC6UQ2MhZbv7sY=
ray=a2c59c31fb024efb ts=MTc4NjkzNzUzMQ== sha256-P/6WIZH1ogc/zbiwpSMvhvdJ/oFRb53PQSj5rhUE6no=
Three requests, three hashes. The script is unique per response, so no static hash can ever
allow it. This is not the ppaby.com situation from the first table, where naming a host in
script-src was enough. There is no allowlist entry that fixes an inline script whose bytes
change every time, and nothing tells you — the page renders, the deploy is green, the feature
is just off in every browser that respects your policy.
Cloudflare’s own CSP
guidance
is to allow /cdn-cgi/challenge-platform/ and keep script-src 'self'. My policy already
does both, which is exactly why this is easy to miss: the external fetch would be fine. It
never gets requested, because the inline wrapper that would request it is blocked first.
The documented escape hatch is nonces. Cloudflare parses your CSP response header and adds
your nonce to the scripts it injects — with the caveat that a nonce set via <meta> tag is
not supported. When I compared the two approaches I
concluded hashes win for static sites, and this is a line item I didn’t have on the other
side. It is a weaker one than it first looks, though: minting a nonce on a fully static site
means putting a Worker in front of every HTML response, and that post’s whole argument against
nonces was that such a Worker stamps its nonce on whatever inline script is in the document —
including one that shouldn’t be there. Buying back a bot-detection feature by weakening the
mechanism that would catch an injected script is not a trade I want.
Check your own site
One line, no dashboard:
curl -s https://example.com/ | grep -o -e 'cloudflareinsights[^"'"'"']*' -e 'challenge-platform[^"'"'"']*'
Match what comes back against this list:
- Beacon with a
Cloudflare Pages Analyticscomment. Per-project. Workers & Pages → project → Metrics. - Beacon with no comment around it. Most likely zone-level RUM, on by default for free
zones since October 2025. Disable it under Web Analytics; the setting is remembered. To
block it from your own code instead, return
Cache-Control: public, no-transform. - Inline
__CF$cv$params. JavaScript Detections. No toggle on Bot Fight Mode. Disable Bot Fight Mode, or upgrade, or accept it.
If only the detections line comes back and the beacon doesn’t, you’re probably on Workers
static assets, which the Pages injection doesn’t reach. And this list isn’t exhaustive:
Email Address Obfuscation rewrites addresses into /cdn-cgi/l/email-protection links with a
decoder script, which I have no email addresses in my HTML to trigger, so it isn’t in any of
my measurements.
Then open the console on your own homepage. If you run a strict CSP, the thing to look for isn’t a missing script. It’s an error you’ve been shipping to every visitor since the day you deployed that policy — one that a single visit in a browser that enforces it would have caught on day one.
I’m leaving mine blocked. That’s now a decision rather than an accident, and the difference between those two is the whole point of measuring.