p4ni.

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/:

HostServed byCSPBeaconJS Detections
p4ni.comPages, custom domainnoneyesyes
p4ni-2li.pages.devPages, pages.devnoneyesno
ui.p4ni.comPages, custom domainnonenoyes
stats.p4ni.comPages, custom domainnonenoyes
ppaby.comPages, custom domainyesyesno
yaso.ppaby.comPages, custom domainnoneyesno
astro.p4ni.comWorkers static assetsyesnoyes
almanac.p4ni.comWorkers static assetsnonenoyes

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:

  1. Beacon with a Cloudflare Pages Analytics comment. Per-project. Workers & Pages → project → Metrics.
  2. 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.
  3. 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.