, 2 min read

Hardening a static site on Cloudflare: the headers I ship

A static site has a small attack surface, but the browser still needs to be told what to trust. These are the response headers this site sends, and why each one is there.

This site is plain HTML, CSS and a little JavaScript, served from Cloudflare’s edge. There is no CMS, no database and no server to patch, which removes most of the usual ways a personal site gets compromised. What is left is the browser: it will happily load scripts, frames and styles from anywhere unless the response tells it not to.

On Cloudflare Workers static assets (and Pages) you set response headers for static files in a _headers file in the build output. Mine looks like this:

/*
  Strict-Transport-Security: max-age=63072000; includeSubDomains
  X-Content-Type-Options: nosniff
  X-Frame-Options: DENY
  Referrer-Policy: strict-origin-when-cross-origin
  Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(), usb=()
  Cross-Origin-Opener-Policy: same-origin
  Content-Security-Policy: default-src 'self'; script-src 'self' https://challenges.cloudflare.com; frame-src https://challenges.cloudflare.com; ...

What each header does

Header Why it is there
Strict-Transport-Security Browsers only ever connect over HTTPS, even when someone types http://.
X-Content-Type-Options Stops the browser from guessing a file’s type and executing something that is not a script.
X-Frame-Options / frame-ancestors Nobody can embed the site in a frame for clickjacking.
Referrer-Policy Other sites see only the domain a visitor came from, not the full URL.
Permissions-Policy Turns off browser features the site never needs.
Content-Security-Policy The allow-list for every script, style, frame and connection.

The Content-Security-Policy is the one that matters

The CSP does the heavy lifting. Scripts may only come from this domain and from Cloudflare Turnstile, which protects the contact form. The page can only send data back to itself (connect-src 'self'), so even an injected script would have nowhere to send what it collected.

Two build settings make a strict policy possible:

  1. Astro is configured with inlineStylesheets: 'never', so CSS ships as files instead of inline <style> blocks.
  2. Page scripts are bundled into files, so script-src needs no 'unsafe-inline'.

Check it yourself

After deploying, run the site through securityheaders.com and Mozilla’s HTTP Observatory. Then open the browser console on every page: a CSP violation shows up there immediately, and it is better that you find it than a visitor.

I have left preload off HSTS for now. Only add it once you are sure every subdomain will serve HTTPS for the long term. Getting removed from the preload list takes months.