A static site has a small attack surface: no database, no server-side sessions, no admin panel. That does not make it secure by default. Missing headers, third-party scripts and a careless contact form can all undo the advantage.

This post explains how I set up this site on Cloudflare Pages and why. The platform details are checked against Cloudflare's documentation as of the date below. The trust page shows the live header list, generated from the same file at build time.

The shape of the site

  • HTML, CSS, fonts and a little JavaScript, all generated ahead of time by a small build script and served from Cloudflare Pages.
  • One server-side component: a Pages Function at /api/contact that turns form submissions into an email.
  • No third-party requests at all. No CDN-hosted libraries, web fonts, analytics or embeds.

The last point matters most. Every external script is code you did not write running with full access to your page. The simplest way to manage that risk is not to have any.

Security headers with _headers

Cloudflare Pages reads a plain-text file called _headers from the build output directory and attaches the headers it lists to matching static responses. The Pages headers documentation describes the format: a URL pattern on one line, then indented Name: value lines. You may define up to 100 header rules.

The rule for every path:

/*
  Content-Security-Policy: default-src 'self'; img-src 'self' data:; font-src 'self'; style-src 'self'; script-src 'self'; connect-src 'self'; base-uri 'none'; object-src 'none'; form-action 'self'; frame-ancestors 'none'; worker-src 'self'
  X-Content-Type-Options: nosniff
  X-Frame-Options: DENY
  Referrer-Policy: strict-origin-when-cross-origin
  Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=()
  Strict-Transport-Security: max-age=31536000; includeSubDomains
  Cross-Origin-Opener-Policy: same-origin

What each does:

HeaderPurpose
Content-Security-PolicyOnly load resources from this origin. No inline script or style, no plugins, no <base> tag, forms post only here, nobody may frame the site, and background workers may load only from this origin. See MDN's CSP reference.
X-Content-Type-Options: nosniffBrowsers must respect the declared content type instead of guessing.
X-Frame-Options: DENYOlder browsers' version of frame-ancestors 'none'.
Referrer-PolicySend only the origin, not the full URL, to other sites.
Permissions-PolicyDisable camera, microphone, geolocation and payment APIs the site never uses.
Strict-Transport-SecurityBrowsers use HTTPS only, for a year, including subdomains.
Cross-Origin-Opener-PolicyIsolate this site's browsing context from pages that open it.

Two cautions on HSTS: includeSubDomains applies to every subdomain, so make sure they all serve HTTPS first. And once browsers have seen the header, removing it does not undo it until max-age passes.

The CSP has to be true

A strict CSP only works if the site obeys it. That meant rules from the start:

  • No inline <script>, except JSON-LD structured data, which browsers do not execute.
  • No inline <style>, no style= attributes, no onclick= handlers.
  • Fonts self-hosted. Icons as inline SVG.
  • The theme toggle and tools are external JavaScript files from the same origin.

The test suite loads every page in a headless browser and fails on any CSP violation or any request to another host.

Caching

HTML is sent with no-cache, so browsers revalidate and see updates immediately. CSS and JavaScript files have a content hash in their names and are cached for a year, because a changed file gets a new name. Fonts are also cached for a year.

The contact form

The form posts JSON to /api/contact, a Pages Function. Important detail from the documentation: **custom headers in _headers are not applied to responses generated by Pages Functions.** The function sets its own response headers.

Limit where Functions run

By default, a Functions project can invoke Functions on every route. A _routes.json file controls this. The Pages Functions routing documentation describes it: a version (currently 1), plus include and exclude lists, with no more than 100 rules combined and no more than 100 characters each. Mine includes only /api/*, so every other request is served as a static file without running code.

{
  "version": 1,
  "include": ["/api/*"],
  "exclude": []
}

What the function checks

The handler treats every request as untrusted:

  • POST only; anything else gets 405.
  • Content-Type: application/json only; anything else gets 415.
  • A small body size limit.
  • The Origin header must be this site.
  • A strict schema: known fields only, length limits, a valid email address.
  • A hidden honeypot field. If it is filled in, the request is accepted and silently discarded.
  • Control characters and line breaks stripped from fields that go into email headers, which prevents header injection.
  • The email is sent as plain text, never HTML.
  • Errors are generic. Message contents are never logged.

The API key for the email provider lives in an environment variable in Cloudflare, not in the repository. If any required variable is missing, the function returns 503 and the page shows the email address instead.

Rate limiting

Abuse protection belongs in front of the function. Cloudflare's WAF rate-limiting rules can limit requests to /api/contact per IP. Configure this in the dashboard.

Redirects

Pages also reads a _redirects file. The redirects documentation limits it to 2,000 static and 100 dynamic redirects. I use only a handful, all to fixed paths on this domain, so there is no open-redirect risk from user-controlled destinations.

security.txt

I publish /.well-known/security.txt per RFC 9116, with an Expires date set by the build script. Some hosts do not serve dot-directories by default, so check the live URL after deploying.

What this does not cover

  • Your Cloudflare account. Use a strong, unique password and multi-factor authentication. Anyone with that account can change everything above.
  • Your DNS and registrar accounts, for the same reason.
  • Your build pipeline. If CI can deploy, protect who can change it.

A checklist for your own static site

  1. Remove third-party scripts you do not need. Self-host the rest.
  2. Write a _headers file with a CSP that matches what the site actually loads.
  3. Test it in report-only mode, then enforce.
  4. If you use Functions, restrict them with _routes.json and set headers in the function.
  5. Validate every form input server-side, and rate-limit.
  6. Publish security.txt.
  7. Check the live headers after every deploy.

Sources