Securing a static site on Cloudflare Pages: what is done on this site
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/contactthat 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:
| Header | Purpose |
|---|---|
Content-Security-Policy | Only 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: nosniff | Browsers must respect the declared content type instead of guessing. |
X-Frame-Options: DENY | Older browsers' version of frame-ancestors 'none'. |
Referrer-Policy | Send only the origin, not the full URL, to other sites. |
Permissions-Policy | Disable camera, microphone, geolocation and payment APIs the site never uses. |
Strict-Transport-Security | Browsers use HTTPS only, for a year, including subdomains. |
Cross-Origin-Opener-Policy | Isolate 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>, nostyle=attributes, noonclick=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:
POSTonly; anything else gets405.Content-Type: application/jsononly; anything else gets415.- A small body size limit.
- The
Originheader 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
- Remove third-party scripts you do not need. Self-host the rest.
- Write a
_headersfile with a CSP that matches what the site actually loads. - Test it in report-only mode, then enforce.
- If you use Functions, restrict them with
_routes.jsonand set headers in the function. - Validate every form input server-side, and rate-limit.
- Publish
security.txt. - Check the live headers after every deploy.
Sources
- Cloudflare Docs, Pages: Headers. Last verified 2026-10-13.
- Cloudflare Docs, Pages Functions: Routing. Last verified 2026-10-13.
- Cloudflare Docs, Pages: Redirects. Last verified 2026-10-13.
- IETF, RFC 9116. Last verified 2026-10-13.
- W3C, Content Security Policy Level 3. Last verified 2026-10-13.
- MDN Web Docs, Content-Security-Policy. Last verified 2026-10-13.