Hardening a Static Site on Cloudflare: TLS, Headers and a Hash-Based CSP
The security configuration behind this site: a per-page Content Security Policy with script hashes and no unsafe-inline, hardening headers served from Workers static assets, and the zone settings that back them up.
On this page
Why a static site still needs hardening
A static site has no server-side code to exploit, so the remaining attack surface is the browser: injected scripts, clickjacking, downgrade to plaintext, and third-party assets that change under you. All of those are controlled by response headers and by what the HTML itself allows. This article shows the configuration this site ships with, verified against the running preview, and the Cloudflare zone settings that make the headers meaningful.
The result is a page that:
- executes only scripts whose SHA-256 hash was computed at build time, with no
unsafe-inlineand nounsafe-eval; - cannot be framed, sniffed, or loaded over HTTP;
- sends no referrer beyond the origin to third parties and grants no browser capabilities it does not use;
- serves hashed assets as immutable and HTML as always-revalidate.
Architecture
- Edge TLS. Cloudflare terminates TLS with a minimum of TLS 1.2, TLS 1.3 enabled, HTTP/2 and HTTP/3. “Always Use
HTTPS” redirects plaintext requests, and the
Strict-Transport-Securityheader tells browsers not to try HTTP again for a year. - WAF and cache. The free managed ruleset runs before the cache. Cache behaviour is governed by the
Cache-Controlheaders the site sets itself, so HTML is revalidated and hashed assets are cached for a year. - Workers static assets. The build output (
dist/) is uploaded as the Worker’s assets. There is no Worker script, which means no code runs per request and nothing can be injected at the edge. The_headersfile in the output adds the hardening headers to every response. - Protected
mainis the only source of production. Every change is validated locally (pnpm validate: format, lint, type check, unit tests, build, link, SEO and header checks, dependency audit and Trivy) before it is merged intomainand pushed; GitLab hosts the source, runs no pipeline, and nothing deploys from anywhere else. The GitLab pipeline this site ran earlier is described in its own article. - Workers Builds. Cloudflare’s Git integration clones the pushed commit, runs the Astro build and uploads the
output with
wrangler deploy. Its token never enters GitLab. During the build Astro computes the hash of every<script>and<style>it emits and writes aContent-Security-Policy<meta>tag into each page. Because the policy lists hashes rather than'unsafe-inline', a script injected into the DOM at runtime does not execute.
Nothing sensitive flows through this diagram. The trust boundary is between the browser and the edge; everything behind the edge is a build artifact produced from reviewed source.
Prerequisites
- A static site (this one is Astro 7; the header and zone sections apply to any generator)
- A Cloudflare zone for the domain and a Workers static-assets deployment (or Pages: the
_headersfile format is identical) - Wrangler 4.x locally to preview
dist/with the real header behaviour:wrangler dev -
curlfor verification
Implementation
1. A hash-based Content Security Policy at build time
Astro can generate the CSP for you. The configuration below is the site’s real one:
export default defineConfig({
site: 'https://sachinchaurasiya.com',
output: 'static',
build: {
// Keep every stylesheet external so the hash list stays short.
inlineStylesheets: 'never',
},
security: {
csp: {
algorithm: 'SHA-256',
directives: [
"default-src 'self'",
"img-src 'self' data:",
"font-src 'self'",
"connect-src 'self' https://cloudflareinsights.com",
"manifest-src 'self'",
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
'upgrade-insecure-requests',
],
scriptDirective: {
// 'wasm-unsafe-eval' is required by Pagefind's WebAssembly search index.
// static.cloudflareinsights.com serves the Cloudflare Web Analytics beacon.
resources: ["'self'", "'wasm-unsafe-eval'", 'https://static.cloudflareinsights.com'],
},
styleDirective: {
resources: [
{ resource: "'self'", kind: 'element' },
// Shiki emits token colours as inline style attributes. Allow attributes only;
// <style> elements remain hash-controlled.
{ resource: "'unsafe-inline'", kind: 'attribute' },
],
},
},
},
});
The generated tag on the homepage looks like this (hashes truncated):
<meta http-equiv="content-security-policy" content="
default-src 'self'; img-src 'self' data:; font-src 'self';
connect-src 'self' https://cloudflareinsights.com; manifest-src 'self';
object-src 'none'; base-uri 'self'; form-action 'self'; upgrade-insecure-requests;
script-src 'self' 'wasm-unsafe-eval' https://static.cloudflareinsights.com
'sha256-3EmFs7tf…' 'sha256-5oP03De9…' …;
style-src 'self'; style-src-elem 'self' 'sha256-vv9IoKo7…'; style-src-attr 'unsafe-inline'">
Each exception in that policy is a documented decision:
| Directive value | Why it exists | What it still forbids |
|---|---|---|
script-src 'sha256-…' per page | Astro’s component scripts (theme toggle, copy buttons, TOC) are inlined and hashed | Any script not present at build time, including injected <script> tags |
'wasm-unsafe-eval' | Pagefind compiles its search index as WebAssembly | eval() and new Function(): this flag covers WebAssembly only |
https://static.cloudflareinsights.com | Cloudflare Web Analytics beacon, loaded only when a token is configured | Every other third-party host |
style-src-attr 'unsafe-inline' | Shiki syntax highlighting writes token colours as style="" attributes | Inline <style> elements (still hash-controlled via style-src-elem) |
img-src data: | Small inline SVG data URIs | Images from any other origin |
object-src 'none', base-uri 'self' | No plugins; <base> cannot be repointed to make relative script URLs load from elsewhere |
Two things to know about a CSP delivered in a <meta> tag rather than an HTTP header. First, frame-ancestors,
report-uri and sandbox are ignored in <meta>; frame-ancestors is therefore set in _headers below. Second, the
<meta> tag must appear before any resource it is meant to govern; Astro places it at the top of <head>.
The theme bootstrap script is a good example of the discipline this forces. It has to run before first paint to avoid
a light-mode flash, which is the classic excuse for an inline <script>. Instead it lives at
public/scripts/theme-init.js and is loaded with <script src>, so it is covered by 'self' and needs no hash at
all.
2. Hardening headers from _headers
Workers static assets (and Cloudflare Pages) read a _headers file from the output directory and apply the rules to
matching paths. This is the site’s file:
/*
Content-Security-Policy: frame-ancestors 'none'
Strict-Transport-Security: max-age=31536000; includeSubDomains
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=(), interest-cohort=()
Cross-Origin-Opener-Policy: same-origin
X-Permitted-Cross-Domain-Policies: none
# Hashed build assets (CSS, JS, fonts) are immutable. One rule only: Cloudflare
# concatenates values when several rules set the same header for a path.
/_astro/*
Cache-Control: public, max-age=31536000, immutable
# Pagefind index chunks are content-addressed as well.
/pagefind/*
Cache-Control: public, max-age=86400
# HTML: always revalidate at the edge/browser; Cloudflare caches per zone rules.
/*.html
Cache-Control: public, max-age=0, must-revalidate
What each line buys:
Content-Security-Policy: frame-ancestors 'none'is the one CSP directive that must be a header. Browsers merge it with the<meta>policy and enforce both.X-Frame-Options: DENYis kept for the handful of clients that predateframe-ancestors.- HSTS at one year with
includeSubDomains. Thepreloadtoken is deliberately absent until the domain has run on HTTPS for a while; preloading is close to irreversible. X-Content-Type-Options: nosniffstops a browser from executing a mislabelled response as script.Referrer-Policy: strict-origin-when-cross-originsends the full URL to same-origin requests and only the origin to other sites, and nothing at all on a downgrade.Permissions-Policyswitches off device APIs the site never uses.interest-cohort=()opts out of the now-retired FLoC and is harmless to keep.Cross-Origin-Opener-Policy: same-originisolates the browsing context fromwindow.openerattacks.- Cache-Control is split by path. Astro names assets by content hash, so
/_astro/*can be immutable for a year; a changed file gets a new name. HTML ismax-age=0, must-revalidateso a deploy is visible on the next request.
The comment in the file about concatenation is there because of a real mistake: an earlier version had both
/_astro/* and /_astro/*.woff2 rules, and fonts were served with the header value doubled
(immutable, public, max-age=31536000, immutable). Harmless, but sloppy, and the kind of thing curl -I finds in
seconds.
3. Trailing slashes and 404s in wrangler.jsonc
{
"name": "sachinchaurasiya-com",
"compatibility_date": "2026-09-01",
"assets": {
"directory": "./dist",
// Serve dist/404.html with a real 404 status for unknown paths.
"not_found_handling": "404-page",
// Astro builds with trailingSlash: 'never'; redirect /path/ → /path.
"html_handling": "drop-trailing-slash",
},
"observability": { "enabled": true },
}
not_found_handling: "404-page" matters for security tooling and search engines alike: an unknown path returns a
404 status with the site’s own error page rather than a 200 with a soft error. drop-trailing-slash gives every
page exactly one URL, which keeps the canonical tag, the sitemap and any future WAF rule in agreement.
4. Zone settings that back the headers
Headers are promises the origin makes; the zone settings decide whether the connection that carries them is sound. Applied to this zone, under SSL/TLS and Network:
| Setting | Value | Reason |
|---|---|---|
| Encryption mode | Full (strict) | Not relevant for Workers assets (no origin), but set anyway so a future origin cannot be attached over plaintext by accident |
| Always Use HTTPS | On | 301 for http:// before the site is ever touched |
| Minimum TLS version | 1.2 | TLS 1.0/1.1 are deprecated (RFC 8996); nothing that matters still needs them |
| TLS 1.3 | On | Default; faster handshakes, no legacy ciphers |
| HTTP/2, HTTP/3 | On | Default |
| Automatic HTTPS Rewrites | On | Rewrites http:// subresource references in HTML; the CSP’s upgrade-insecure-requests covers the rest |
| HSTS (zone level) | Off | The site sends HSTS itself; enabling both only complicates changing the value later |
| DNSSEC | Enable after go-live | Signs the zone so DNS responses cannot be spoofed; one click when the registrar is Cloudflare |
| WAF | Managed ruleset (free) | Blocks known-bad request patterns before they reach the cache |
| Bot Fight Mode | Off | It challenges monitoring tools and has no benefit for a site with no forms |
And one Redirect Rule so the site has a single canonical host:
When: (http.host eq "www.sachinchaurasiya.com")
Then: Dynamic redirect, status 301
concat("https://sachinchaurasiya.com", http.request.uri.path)
Preserve query string: on
Both sachinchaurasiya.com and www are attached as custom domains on the Worker, so the redirect happens at the edge
with a valid certificate for either name.
Verification
Run the checks against the production host once deployed, and against wrangler dev before that. The output below is
from the local preview, which uses the same assets runtime as production.
curl -sI http://localhost:8788/ | grep -iE 'strict-transport|content-security|x-content-type|referrer|permissions|x-frame|opener'
Strict-Transport-Security: max-age=31536000; includeSubDomains
content-security-policy: frame-ancestors 'none'
cross-origin-opener-policy: same-origin
permissions-policy: accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=(), interest-cohort=()
referrer-policy: strict-origin-when-cross-origin
x-content-type-options: nosniff
x-frame-options: DENY
Confirm the page-level policy has no unsafe-inline in script-src and no unsafe-eval anywhere:
curl -s http://localhost:8788/ | grep -o 'content-security-policy" content="[^"]*"' \
| grep -oE "unsafe-[a-z-]+" | sort | uniq -c
1 unsafe-eval # the substring of 'wasm-unsafe-eval'; plain 'unsafe-eval' is absent
1 unsafe-inline # style-src-attr only (Shiki)
Then the cache split and the URL canonicalisation:
curl -sI http://localhost:8788/_astro/BaseLayout.CJfLqgRM.css | grep -i cache-control # immutable
curl -sI http://localhost:8788/blog/ | head -2 # 307 → /blog
curl -sI http://localhost:8788/does-not-exist | head -1 # 404
Finally, load the site in a browser with DevTools open and click through the search page, the theme toggle and a code
block’s copy button. A CSP that is too tight shows up here as Refused to execute inline script errors, and this is
the only place they show up: the site has no reporting endpoint, because report-uri does not work from <meta> and
a reporting service is one more third party.
For an outside opinion, the Mozilla HTTP Observatory and
securityheaders.com both parse these headers; the CSP evaluator at
csp-evaluator.withgoogle.com is stricter about style-src-attr than is
warranted for a highlighted-code site, and says so.
Security implications and limits
- What CSP does not cover. Data exfiltration through
img-src 'self'is impossible, but throughconnect-srctocloudflareinsights.comis technically allowed; that host is Cloudflare’s, and the beacon script is loaded by the site itself. If the analytics token is ever removed, remove the two hosts from the policy in the same change. style-src-attr 'unsafe-inline'is the one relaxation. Attribute-level CSS injection can restyle a page and, in contrived cases, leak data throughbackground-imageURLs, which is whyimg-srcstays locked to'self'.- HSTS without preload protects returning visitors, not the very first request. Preload closes that gap and is the planned next step once the domain has been stable on HTTPS.
- Failure modes. A wrong hash in the policy does not fail the build; it fails in the browser as a refused script.
That is why
pnpm verifyruns the site locally and why the checklist above is part of every release. A missing_headersfile failspnpm check:headers, which greps for HSTS andframe-ancestorsin the build output; the change is not merged without them.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
Refused to execute inline script after adding a component | An is:inline script or set:html with a <script> bypasses Astro’s hashing | Move the code to a normal component <script> (hashed) or an external file under public/scripts/ |
| Syntax highlighting renders in one colour | style-src-attr missing; Shiki’s style="" attributes are blocked | Keep the attribute kind relaxation, or switch Shiki to CSS-variable classes |
| Search page shows no results | 'wasm-unsafe-eval' missing from script-src | Add it; Pagefind needs WebAssembly |
| Header appears twice, comma-joined | Two _headers rules match the same path and set the same header | Keep one rule per header per path; check with curl -I |
| Fonts blocked | font-src does not include the host serving them | Self-host fonts (Fontsource) so 'self' covers them |
http:// URL loads the site without a redirect | “Always Use HTTPS” off, or DNS not yet proxied through Cloudflare | Enable the setting; confirm the record is proxied (orange cloud) |
Running this in production
- Re-check headers after every Cloudflare or Wrangler upgrade.
compatibility_datechanges the assets runtime’s behaviour;pnpm check:headersand a post-deploycurl -Imake regressions visible. - When adding any third-party resource (fonts, analytics, embeds), add the host to the narrowest directive that
works and record why in
astro.config.mjs. A CSP is documentation of every external trust the page has. - Enable DNSSEC and, later, HSTS preload as separate, deliberate changes with their own verification.
- Keep the token that deploys the Worker out of the repository and out of CI. Here it is the one Cloudflare generates for Workers Builds; it deploys Workers and nothing else. The zone’s TLS, WAF and DNS settings are changed by a person in the dashboard, and that separation is intentional.
References
- Astro: Content Security Policy (
security.csp) - Cloudflare Workers static assets: headers and routing
- Cloudflare: Redirect Rules
- MDN: Content-Security-Policy, Strict-Transport-Security, Permissions-Policy
- OWASP HTTP Headers Cheat Sheet
- RFC 8996: Deprecating TLS 1.0 and TLS 1.1
- Source:
astro.config.mjs,public/_headers,wrangler.jsonc
Keep reading