08 / writing
Adding a Content-Security-Policy to a static Astro site on Cloudflare
I have deployed my own projects on Cloudflare, and this portfolio is one of them. I currently work at Future ProTechY, where I work with AWS, but for personal sites Cloudflare is what I use, so this is written from that side.
This site is a static Astro build deployed on Cloudflare. A Content-Security-Policy (CSP) tells the browser which sources it may load scripts, styles, images and connections from, so a script injected into a page cannot load code from somewhere you did not approve. On a static site there is no server code to set the header, but Cloudflare lets you do it with one file.
Where the headers live
Put a file called _headers in Astro’s public/ folder. Astro copies public/ into the build output, and Cloudflare reads _headers from there:
/*
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Strict-Transport-Security: max-age=31536000; includeSubDomains
Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=()
Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https://img.shields.io; connect-src 'self' https://formspree.io; form-action 'self' https://formspree.io; base-uri 'self'; object-src 'none'; frame-ancestors 'self'
The /* line means “every path”. This file is a Cloudflare and Netlify convention. Hosts such as GitHub Pages ignore it, so check where your site is actually served from before trusting it.
I check the result from the command line instead of guessing:
curl -sI https://your-site.example/ | grep -i content-security-policy
How I built the policy
I started from default-src 'self', which blocks everything not served from my own domain, and then added only what the site really loads:
- Google Fonts:
fonts.googleapis.comfor the stylesheet andfonts.gstatic.comfor the font files. - Contact form:
formspree.ioin bothconnect-src(thefetchcall) andform-action(the plain form fallback). - Badges:
img.shields.ioinimg-src, because the skills section loads badge images from there. - No plugins or framing:
object-src 'none'andframe-ancestors 'self'.
The reliable way to find what you need is to load every page with the browser console open and read the “Refused to load…” errors.
Gotcha 1: it blocks Cloudflare’s own scripts
Cloudflare Web Analytics loads a script from static.cloudflareinsights.com and sends data to cloudflareinsights.com. My first policy allowed neither, so the analytics would have stopped working while the site looked completely normal. The fix is two more entries: the script host in script-src and the reporting host in connect-src.
If you turn on other Cloudflare features that inject scripts (Rocket Loader, Turnstile, email obfuscation), expect the same thing and test again after enabling each one.
Gotcha 2: 'unsafe-inline' on a static site
A strict CSP uses a random nonce on each response so that only your own inline scripts run. A static site has no per-request code, so it cannot generate nonces. Astro and the page’s own theme script both use inline scripts, which means script-src needs 'unsafe-inline'.
That makes the policy weaker against injected inline scripts than a nonce-based one. What it still does well: it blocks scripts from other domains, stops the page being framed by other sites, blocks plugins, and limits where forms can post. For a static portfolio with no user-generated content, that is a reasonable trade. If you need strict mode, you need a server or an edge function that can add nonces or hashes.
Gotcha 3: test before you enforce
A mistake in a CSP breaks the page for every visitor. Cloudflare lets you send the same policy under a different header name, which only reports violations in the console instead of blocking:
Content-Security-Policy-Report-Only: default-src 'self'; ...
Run it that way, fix everything the console complains about, then rename the header to Content-Security-Policy.
Checklist
- Every page, not just the home page, loaded with the console open
- The contact form actually submitted
- Fonts and images visible
- Analytics still receiving data
curl -Ishows the headers on the live site