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:
- Astro is configured with
inlineStylesheets: 'never', so CSS ships as files instead of inline<style>blocks. - Page scripts are bundled into files, so
script-srcneeds 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
preloadoff 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.