DEV Community

Cover image for Three Cloudflare Patterns Earned the Hard Way
David Bartalos
David Bartalos

Posted on

Three Cloudflare Patterns Earned the Hard Way

Every Cloudflare product is well-documented in isolation. The interesting bugs are always at the seams between two products — the edge injecting scripts into HTML that already has a CSP, the WAF inspecting requests for media served from R2, Vite substituting variables at build time that don't exist yet on Pages. These three patterns are from running nadiapoe.co.uk on Astro 6 + Pages, with R2 hosting the artwork media.

1. CSP nonces via middleware, not build-time hashes

The textbook way to do a strict CSP on a static site is build-time hashing: scan every inline <script>, hash it, list the hashes in the CSP header. Cloudflare even has a build hook to do it for you.

For an art site, Bot Fight Mode is non-negotiable — automated scrapers harvesting painting images are a real concern, and Cloudflare's challenge platform is the cheapest layer of protection available. But it breaks the moment you enable it. Cloudflare's edge injects the challenge widget at request time with a rotating token. The hash changes per request. Your CSP rejects it. Half your visitors get a broken challenge widget because the static hash you baked in this morning no longer matches.

The fix is a per-request nonce. Astro 6's middleware runs as a Worker; Workers have HTMLRewriter. From client/src/middleware.ts:

export const onRequest = defineMiddleware(async (context, next) => {
  const response = await next()
  if (typeof HTMLRewriter === 'undefined') return response
  if (!response.headers.get('content-type')?.includes('text/html')) return response

  const nonceBytes = crypto.getRandomValues(new Uint8Array(16))
  const nonce = btoa(String.fromCharCode(...nonceBytes))

  const rewritten = new HTMLRewriter()
    .on('script', { element(el) { el.setAttribute('nonce', nonce) } })
    .transform(response)

  const headers = new Headers(rewritten.headers)
  headers.set('Content-Security-Policy', buildCsp(nonce))
  // private cache — never share a nonce between users
  headers.set('Cache-Control', 'private, max-age=1500, stale-while-revalidate=7200')

  return new Response(rewritten.body, { status: rewritten.status, headers })
})
Enter fullscreen mode Exit fullscreen mode

Two non-obvious bits:

  • Cloudflare reads the nonce from your CSP header and stamps its own injected scripts with it. Undocumented but stable. This is the only reason the pattern works at all — without it, the challenge widget would still get blocked.
  • Cache-Control: private is load-bearing. A shared cache that served one user's nonced HTML to another would only break the cached client (their nonce doesn't match the newly injected scripts), but it's still a bug. private keeps Cloudflare's edge from doing this.

2. Server secrets via cloudflare:workers, not import.meta.env

Astro has two environments: build-time and runtime. Vite resolves import.meta.env.SOMETHING at build time by string substitution. If the value isn't in .env at build time — and Cloudflare Pages doesn't expose dashboard-configured secrets during the build — Vite bakes in the literal undefined.

Worse: it does this silently. No warning, no error. The contact form deploys, the Resend SDK call fails with "API key undefined," and you spend an hour checking the Cloudflare dashboard before realising the value never made it into the bundle.

Astro 6 has two ways to read server-side env at runtime. They are not equivalent:

// ❌ Broken in Astro 6 — Astro.locals.runtime.env was removed
const apiKey = Astro.locals.runtime.env.RESEND_API_KEY

// ✅ Works
import { env } from 'cloudflare:workers'
const apiKey = env.RESEND_API_KEY
Enter fullscreen mode Exit fullscreen mode

The cloudflare:workers module is provided by the Workers runtime. env is the live binding object — the same one your wrangler.toml and Cloudflare dashboard configure. No build-time substitution; nothing is baked in.

Client-side code (anything in a Svelte component or a non-SSR Astro page) keeps using import.meta.env.PUBLIC_*. Those are baked at build time on purpose — they're public.

One gotcha: bindings may be absent in local dev without a full Pages emulation setup. Wrap access in try/catch when the value is optional:

function getDb() {
  try { return (env as any)?.DB ?? null } catch { return null }
}
Enter fullscreen mode Exit fullscreen mode

3. R2 hotlink protection with an inverted WAF rule

Print fulfillment runs through Prodigi — a print-on-demand provider that produces and ships prints worldwide. When an order comes in, Medusa generates a time-gated presigned R2 URL pointing to the high-resolution print master and hands it to Prodigi. Prodigi fetches the file, prints it, ships it. The URL expires. Nobody else ever needs access to that file.

The high-res masters live in R2. Keeping them protected means ensuring those presigned URLs can only be opened by the intended recipient in the intended window — not scraped, not re-shared, not embedded on another site. The WAF rule is the layer that enforces origin intent on the media domain.

The naive WAF rule is "block if the Referer header doesn't match nadiapoe.co.uk."

It works until you try to:

  • Open an image URL directly in a browser tab — no Referer, blocked.
  • Load a <video preload="metadata"> tag — some browsers send no Referer on media requests, blocked.
  • Test on a *.pages.dev preview deployment — Referer is <branch>.nadiapoe-co-uk.pages.dev, blocked.

The fix is to invert the logic: block only when a Referer exists and isn't in the allowlist.

(http.host eq "media.nadiapoe.co.uk")
and (len(http.referer) > 0)
and not (http.referer contains "nadiapoe.co.uk")
and not (http.referer contains "localhost")
and not (http.referer contains ".pages.dev")
Enter fullscreen mode Exit fullscreen mode

Empty Referers pass — direct nav, preload requests, RSS readers. Preview deployments pass via the .pages.dev wildcard. Real hotlinks from other sites get a 403.

If you script your WAF rules via the Cloudflare API (which you should, for reproducibility), two quirks not mentioned in the error messages: a PUT to the rulesets endpoint must not include kind or phase (they're implicit from the URL), and rate-limit characteristics on the free plan must include cf.colo.id because counts are per-colo, not global.

Closing

The seams are where you learn things. Check the network tab when behaviour is wrong — Cloudflare adds its own response headers that tell you which rule fired, which challenge ran, which WAF block triggered. The answers are usually there; they're just not in the docs.

nadiapoe.co.uk runs on all three patterns in production.

Top comments (0)