Privacy & Security

Zero client-side JavaScript from your CDN: a Cloudflare hardening guide

You built a static site and promised no tracking. Then Cloudflare started injecting JavaScript you never asked for. Here is how to stop it and lock everything else down.

12 min read
Free Guide

You built a static site. You told your users there's no tracking. Every file is processed in their browser. Nothing phones home.

Then you put it behind Cloudflare.

Cloudflare is excellent infrastructure. But out of the box, it can inject scripts into your HTML, set cookies, rewrite markup, and send telemetry to its own endpoints. Most of these features are well-intentioned performance optimisations. They're also a problem if you promised your users zero client-side JavaScript from your CDN.

This guide covers every Cloudflare feature that can modify what your visitors receive, how to audit your own zone, and how to lock things down properly. It's based on a real audit we ran on unwrite.co.

Everything Cloudflare can inject

Before disabling anything, you need to know what's available. Here's the full list of Cloudflare features that can inject JavaScript, modify HTML, or set cookies on your responses.

FeatureWhat it doesInjects JS?Modifies HTML?Sets cookies?
Rocket LoaderWraps all scripts in a deferred async loaderYesYesNo
Email ObfuscationEncodes email addresses, injects decoder scriptYesYesNo
Speed BrainSpeculative prefetch using speculation rules JSYesYesNo
Cloudflare FontsRewrites Google Fonts to serve from Cloudflare edgeYesYesNo
MirageLazy-loads images with placeholder JSYesYesNo
Web Analytics / RUMInjects beacon.min.js for real user monitoringYesYesNo
Auto MinifyStrips whitespace from HTML, CSS, JSNoYesNo
Server Side ExcludesStrips HTML between SSE tags for suspicious IPsNoYesNo
Automatic HTTPS RewritesRewrites http:// links to https:// in HTMLNoYesNo
NEL (Network Error Logging)Adds reporting headers that phone home on errorsNoNoNo
Early HintsSends 103 headers for preloading assetsNoNoNo
Bot ManagementServes interstitial challenge pagesYesYesYes
ZarazServer-side tag manager, can inject client JSYesYesYes

That's thirteen features. Six inject JavaScript. Nine modify your HTML. If you've got Rocket Loader and Email Obfuscation both enabled, Cloudflare is adding two scripts to every page you never wrote.

The audit process

Don't guess which features are on. Query the API.

Create an API token in the Cloudflare dashboard with Zone Settings Read and Zone Analytics Read permissions. Then check your zone configuration.

Bulk settings

GET https://api.cloudflare.com/client/v4/zones/{zone_id}/settings

This returns most settings, but not all. Some newer features have their own endpoints.

Individual settings not in the bulk response

GET https://api.cloudflare.com/client/v4/zones/{zone_id}/settings/speed_brain
GET https://api.cloudflare.com/client/v4/zones/{zone_id}/settings/fonts
GET https://api.cloudflare.com/client/v4/zones/{zone_id}/settings/nel

Traffic data (for the bot analysis later)

POST https://api.cloudflare.com/client/v4/graphql

Use the GraphQL API with the httpRequestsAdaptiveGroups dataset. Group by clientRequestHTTPHost, clientRequestUserAgent, or clientAsn to spot patterns.

Save the bulk settings response. Diff it after you make changes. You want a paper trail showing what was on and what you turned off.

What to disable for a static site

If your site is statically exported and you handle your own script loading, font hosting, and prefetching, most of these features are redundant. Some actively break your privacy promise.

  • Rocket Loader: off. Your framework handles script loading. Rocket Loader wraps every script tag in its own async loader, which can break execution order and adds a script you didn't write.
  • Speed Brain: off. Next.js Link already handles prefetching with its own intersection observer. Speed Brain adds a second, competing prefetch mechanism.
  • Cloudflare Fonts: off. If you're using next/font or self-hosting your fonts, this rewrites perfectly good font references for no benefit.
  • Email Obfuscation: off. If there are no email addresses in your HTML, the decoder script is pure overhead. Even if you do have emails, there are lighter CSS-based approaches.
  • Web Analytics / RUM beacon: off. This is the big one. Cloudflare's Web Analytics injects beacon.min.js, which sends page view data to Cloudflare. If you've told users there's no tracking, this breaks that promise. Server-side analytics or Cloudflare's own dashboard analytics (computed from logs, no client JS) give you traffic data without a beacon.
  • NEL: off. Network Error Logging adds a Report-To header that instructs browsers to send error reports to Cloudflare's endpoint. Your users' browsers phone home to Cloudflare on connection errors. That's telemetry you didn't disclose.
  • Server Side Excludes: off. Unless you're using SSE tags in your HTML (you're almost certainly not), this is doing nothing except adding processing overhead.
  • Auto Minify: off. Your build pipeline already minifies everything. Double-minification can cause issues and makes debugging harder.

After disabling everything, verify by viewing source on a live page. Search for cloudflare, rocket-loader, beacon.min.js, and cdn-cgi. If none appear, you're clean.

WAF rules

Cloudflare's free tier includes five custom WAF rules. Use two of them to block the most common attack patterns.

Rule 1: Block vulnerability scanners

These requests probe for WordPress installs, environment files, and server configuration. If you're running a static site, none of these paths exist. Block them at the edge before they hit your origin.

Cloudflare expression:

(http.request.uri.path contains ".env") or
(http.request.uri.path contains ".git/") or
(http.request.uri.path contains "wp-login") or
(http.request.uri.path contains "wp-admin") or
(http.request.uri.path contains "wp-includes") or
(http.request.uri.path contains "wp-content") or
(http.request.uri.path contains ".php") or
(http.request.uri.path contains "/etc/passwd") or
(http.request.uri.path contains ".aws/")

Action: Block.

You'll be surprised how much traffic this catches. We see hundreds of .env probes daily. Automated scanners crawl every IP range looking for exposed credentials. Blocking at the WAF means your origin never sees these requests.

Rule 2: Block source and config exposure

This rule protects build artefacts, source maps, and configuration files that shouldn't be publicly accessible.

Cloudflare expression:

(http.request.uri.path contains ".js.map") or
(http.request.uri.path contains ".css.map") or
(http.request.uri.path contains ".ts.map") or
(http.request.uri.path contains "tsconfig") or
(http.request.uri.path contains "package.json") or
(http.request.uri.path contains "package-lock") or
(http.request.uri.path contains ".npmrc") or
(http.request.uri.path contains "node_modules/") or
(http.request.uri.path contains ".next/") or
(http.request.uri.path contains ".vscode/") or
(http.request.uri.path contains ".idea/") or
(http.request.uri.path contains ".DS_Store") or
(http.request.uri.path contains ".docker/") or
(http.request.uri.path contains ".ssh/") or
(http.request.uri.path contains "credentials.json") or
(http.request.uri.path contains ".pem") or
(http.request.uri.path contains "id_rsa") or
(http.request.uri.path contains "debug.log") or
(http.request.uri.path contains "yarn.lock") or
(http.request.uri.path contains ".wrangler/")

Action: Block.

Source maps are the most commonly overlooked one. They expose your original source code, including comments, variable names, and file structure. If you're building with Next.js, check whether productionBrowserSourceMaps is enabled in your config. If it is and you're not blocking .map files, your entire source tree is public.

TLS hardening

Two changes. Both take thirty seconds.

Minimum TLS version: 1.2. TLS 1.0 and 1.1 were formally deprecated by RFC 8996 in 2021. Every modern browser supports 1.2. Raising the minimum drops support for Internet Explorer 10 and Android 4.3. That's a trade-off you should be comfortable making.

HSTS (HTTP Strict Transport Security). Enable it with these settings:

  • max-age: 31536000 (one year)
  • includeSubDomains: yes
  • preload: yes

Once you've confirmed HSTS is working, submit your domain to hstspreload.org. This adds your domain to a hardcoded list shipped in every major browser. After that, browsers will never even attempt an HTTP connection to your domain. The first visit is secure, not just subsequent ones.

Be certain before enabling HSTS preload. Removing a domain from the preload list takes months. If any subdomain can't serve HTTPS, sort that out first.

Cache optimisation

Cloudflare's dashboard reported a 27.8% cache hit rate for our zone. That number was misleading. Most of those "hits" were revalidations where Cloudflare checked with the origin and got a 304 Not Modified. The actual cache hit rate, requests served entirely from edge without touching origin, was 0.8%.

The root cause: our origin was sending Cache-Control: public, max-age=0, must-revalidate on every HTML response. Cloudflare respected that header and revalidated on every request.

The fix is a _headers file in your static export root. For Next.js static exports deployed to Cloudflare Pages, this controls response headers.

/_next/static/*
  Cache-Control: public, max-age=31536000, immutable

/service-worker.js
  Cache-Control: public, max-age=0, must-revalidate

/*
  Cache-Control: public, max-age=3600, s-maxage=86400

The logic:

  • /_next/static/*: These files are content-hashed. The filename changes when the content changes. They can be cached forever. immutable tells the browser not to bother revalidating.
  • /service-worker.js: Must always be fresh. Browsers check for service worker updates on navigation. Serving a stale service worker means users don't get your latest code.
  • /*: Everything else gets one hour in the browser cache and one day at the edge. This covers HTML pages, which change when you deploy but don't need to be revalidated on every request.

After deploying these headers, our actual cache hit rate went from 0.8% to over 90% for static assets.

Security headers

While you're editing the _headers file, add these. They're free defence-in-depth.

/*
  X-Frame-Options: DENY
  X-Content-Type-Options: nosniff
  Referrer-Policy: strict-origin-when-cross-origin
  Permissions-Policy: camera=(), microphone=(), geolocation=()

What each does:

  • X-Frame-Options: DENY prevents your site from being embedded in iframes. Stops clickjacking attacks.
  • X-Content-Type-Options: nosniff stops browsers from guessing content types. If your server says it's text/html, the browser treats it as text/html, not as JavaScript.
  • Referrer-Policy: strict-origin-when-cross-origin sends the full URL as referrer for same-origin requests, but only the origin (no path) for cross-origin requests. Your internal navigation works normally. External sites don't see your full URL paths.
  • Permissions-Policy disables browser APIs you don't use. A static site doesn't need camera, microphone, or geolocation access. Declaring that explicitly means even XSS can't activate them.

The bot problem

After setting up our WAF rules and reviewing traffic data, we found that 42% of our requests came from bots. Not Googlebot or Bingbot doing legitimate indexing. Malicious or pointless automated traffic.

The worst offenders:

  • HeadlessChrome monitoring tools: roughly 200 requests per day. Automated uptime checkers or scraping bots running headless Chromium. They request every page, parse nothing useful, and inflate your analytics.
  • Chrome/144 from Hong Kong: roughly 300 requests per day. A scraper fixated on build metadata. It hit /_next/ paths and manifest.json repeatedly, likely building a database of framework versions and configurations across thousands of sites.
  • curl-based .env scanner from France: 2,665 requests in a single day. A single IP cycling through common paths like /.env, /.env.production, /.env.local, /config/.env, and dozens of variations. Looking for exposed API keys and database credentials.
  • Empty User-Agent vulnerability scanners: probing for PHP backdoors, WordPress xmlrpc.php, and common web shell paths. None of which exist on a static site, but they don't know that until they check.

The WAF rules in the earlier section handle most of these by blocking the paths they request. For the remainder, Cloudflare's built-in Bot Fight Mode (available on the free plan) adds friction to automated requests without affecting real users.

Check your own traffic. Pull user agent data from the GraphQL API grouped by clientRequestUserAgent and clientRequestHTTPHost over the past 30 days. The results will probably surprise you.

Hardening checklist

Run through this after setting up any static site behind Cloudflare.

Client-side injection:

  • Rocket Loader disabled
  • Speed Brain disabled
  • Cloudflare Fonts disabled
  • Email Obfuscation disabled
  • Web Analytics / RUM beacon disabled or never enabled
  • Mirage disabled
  • Zaraz disabled or not installed

HTML modification:

  • Auto Minify disabled (your build handles this)
  • Server Side Excludes disabled
  • Verified: view source shows no Cloudflare-injected scripts

TLS and transport:

  • Minimum TLS version set to 1.2
  • HSTS enabled with max-age 31536000, includeSubDomains, preload
  • Domain submitted to hstspreload.org
  • SSL mode set to Full (Strict)

WAF:

  • Rule blocking vulnerability scanner paths (.env, .git, wp-*, .php)
  • Rule blocking source and config files (.map, package.json, tsconfig)

Caching:

  • _headers file deployed with correct Cache-Control directives
  • Content-hashed assets set to immutable
  • Service worker set to must-revalidate
  • Verified actual cache hit rate (not Cloudflare's reported rate)

Security headers:

  • X-Frame-Options: DENY
  • X-Content-Type-Options: nosniff
  • Referrer-Policy configured
  • Permissions-Policy restricting unused APIs

Network and reporting:

  • NEL disabled
  • Verified no Report-To headers in responses

Monitoring:

  • Reviewed bot traffic patterns via GraphQL API
  • Bot Fight Mode enabled
  • Checked for unexpected cookies in responses

This isn't paranoid. It's the baseline for a site that claims to respect user privacy. If you tell people nothing tracks them, make sure that's actually true, all the way down to the CDN layer.

Read our full privacy policy for how Unwrite handles this.