Sachin Chaurasiya

Cloud Security

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.

Author
Sachin Chaurasiya
Sachin Chaurasiya
Published
Reading time
13 min read
Difficulty
intermediate

Reviewed Tested with Astro 7.3, Wrangler 4.131, Cloudflare Workers static assets

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-inline and no unsafe-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

Diagram · Static site behind the Cloudflare edge
Static site behind the Cloudflare edgeA browser connects to the Cloudflare edge over TLS 1.2 or later with HSTS. The request passes the WAF managed rules and the cache, then is served from Workers static assets. Each HTML page carries a hash-based Content Security Policy generated at build time, and the _headers file adds HSTS, frame-ancestors and the other hardening headers. Deployment is one-directional: after a change passes the local validation suite and is merged into the protected main branch, Cloudflare Workers Builds clones that commit, builds the site (which computes the CSP hashes) and uploads the output with wrangler.CloudflareDeliveryhttpsmisspushwrangler deployBrowserEdge TLSTLS 1.2+ · HTTP/31WAF + cachemanaged rules2Workers staticassetsdist/ + _headers3Protected mainvalidated locally4Workers Buildsastro build · CSP5

A browser connects to the Cloudflare edge over TLS 1.2 or later with HSTS. The request passes the WAF managed rules and the cache, then is served from Workers static assets. Each HTML page carries a hash-based Content Security Policy generated at build time, and the _headers file adds HSTS, frame-ancestors and the other hardening headers. Deployment is one-directional: after a change passes the local validation suite and is merged into the protected main branch, Cloudflare Workers Builds clones that commit, builds the site (which computes the CSP hashes) and uploads the output with wrangler.

  1. 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-Security header tells browsers not to try HTTP again for a year.
  2. WAF and cache. The free managed ruleset runs before the cache. Cache behaviour is governed by the Cache-Control headers the site sets itself, so HTML is revalidated and hashed assets are cached for a year.
  3. 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 _headers file in the output adds the hardening headers to every response.
  4. Protected main is 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 into main and 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.
  5. 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 a Content-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 _headers file format is identical)
  • Wrangler 4.x locally to preview dist/ with the real header behaviour: wrangler dev
  • curl for 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 valueWhy it existsWhat it still forbids
script-src 'sha256-…' per pageAstro’s component scripts (theme toggle, copy buttons, TOC) are inlined and hashedAny script not present at build time, including injected <script> tags
'wasm-unsafe-eval'Pagefind compiles its search index as WebAssemblyeval() and new Function(): this flag covers WebAssembly only
https://static.cloudflareinsights.comCloudflare Web Analytics beacon, loaded only when a token is configuredEvery other third-party host
style-src-attr 'unsafe-inline'Shiki syntax highlighting writes token colours as style="" attributesInline <style> elements (still hash-controlled via style-src-elem)
img-src data:Small inline SVG data URIsImages 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: DENY is kept for the handful of clients that predate frame-ancestors.
  • HSTS at one year with includeSubDomains. The preload token is deliberately absent until the domain has run on HTTPS for a while; preloading is close to irreversible.
  • X-Content-Type-Options: nosniff stops a browser from executing a mislabelled response as script.
  • Referrer-Policy: strict-origin-when-cross-origin sends the full URL to same-origin requests and only the origin to other sites, and nothing at all on a downgrade.
  • Permissions-Policy switches 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-origin isolates the browsing context from window.opener attacks.
  • 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 is max-age=0, must-revalidate so 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:

SettingValueReason
Encryption modeFull (strict)Not relevant for Workers assets (no origin), but set anyway so a future origin cannot be attached over plaintext by accident
Always Use HTTPSOn301 for http:// before the site is ever touched
Minimum TLS version1.2TLS 1.0/1.1 are deprecated (RFC 8996); nothing that matters still needs them
TLS 1.3OnDefault; faster handshakes, no legacy ciphers
HTTP/2, HTTP/3OnDefault
Automatic HTTPS RewritesOnRewrites http:// subresource references in HTML; the CSP’s upgrade-insecure-requests covers the rest
HSTS (zone level)OffThe site sends HSTS itself; enabling both only complicates changing the value later
DNSSECEnable after go-liveSigns the zone so DNS responses cannot be spoofed; one click when the registrar is Cloudflare
WAFManaged ruleset (free)Blocks known-bad request patterns before they reach the cache
Bot Fight ModeOffIt 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 through connect-src to cloudflareinsights.com is 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 through background-image URLs, which is why img-src stays 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 verify runs the site locally and why the checklist above is part of every release. A missing _headers file fails pnpm check:headers, which greps for HSTS and frame-ancestors in the build output; the change is not merged without them.

Troubleshooting

SymptomCauseFix
Refused to execute inline script after adding a componentAn is:inline script or set:html with a <script> bypasses Astro’s hashingMove the code to a normal component <script> (hashed) or an external file under public/scripts/
Syntax highlighting renders in one colourstyle-src-attr missing; Shiki’s style="" attributes are blockedKeep the attribute kind relaxation, or switch Shiki to CSS-variable classes
Search page shows no results'wasm-unsafe-eval' missing from script-srcAdd it; Pagefind needs WebAssembly
Header appears twice, comma-joinedTwo _headers rules match the same path and set the same headerKeep one rule per header per path; check with curl -I
Fonts blockedfont-src does not include the host serving themSelf-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 CloudflareEnable the setting; confirm the record is proxied (orange cloud)

Running this in production

  • Re-check headers after every Cloudflare or Wrangler upgrade. compatibility_date changes the assets runtime’s behaviour; pnpm check:headers and a post-deploy curl -I make 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

Keep reading