Which Security Headers a Static Site Actually Needs
A static site has no server configuration file. That leads people to conclude it has no security headers either, which is not true — the host still sends a response with headers, and on Cloudflare Pages you control them with a plain text file in your build output.
The file is public/_headers. It is small, it is worth writing once, and it has a failure mode that costs people an afternoon.
The format
Rules are blocks. A path pattern starts a block; indented lines beneath it are the headers applied to that pattern.
/*
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
X-Frame-Options: SAMEORIGIN
Permissions-Policy: geolocation=(), microphone=(), camera=()
/* matches every path. When a request matches more than one block, the headers are additive rather than overriding — every matching rule contributes. Define the same header twice on one request and the values are joined with a comma. That makes the patterns composable: a site-wide block for whatever everything needs, narrower blocks for the exceptions.
The five worth setting
X-Content-Type-Options: nosniff
Tells the browser to trust your Content-Type header instead of guessing. Without it, a file served as text/plain that happens to contain markup can be sniffed and executed as HTML. One line, no downside, no maintenance.
Referrer-Policy: strict-origin-when-cross-origin
Controls how much of your URL is sent to other sites when a reader clicks an outbound link. The default in modern browsers is already close to this, but stating it removes the dependency on browser defaults and stops full paths from leaking to third parties. Cross-origin destinations get the origin only; same-origin requests still get the full path.
X-Frame-Options: SAMEORIGIN
Prevents your pages from being embedded in an iframe on another site, which is how clickjacking works. If you specifically want a page embeddable — a widget, a calculator — scope this to /* minus that path rather than turning it off everywhere.
Permissions-Policy
Turns off browser features your site does not use. A content site has no business reading a location or opening a camera:
Permissions-Policy: geolocation=(), microphone=(), camera=()
This is not a security boundary in the way CSP is — it limits what your own code can request. The value is that a compromised third-party script cannot quietly ask for those permissions either.
Strict-Transport-Security
Forces HTTPS for future visits. You can set HSTS through _headers, but there is usually a better place: Cloudflare’s SSL/TLS → Edge Certificates section has a zone-level Always Use HTTPS switch that covers every subdomain at once. One switch beats remembering to add the header in each project.
Removing a header you did not set
Pages sends some headers by default, and a broader rule may have added one you do not want on a particular asset. To strip it, prefix the header name with ! :
/*.jpg
! Content-Security-Policy
That deletes the header for JPEGs and leaves every other path alone.
The one to be careful with
Content-Security-Policy is the header people reach for first and the one that does the most damage when it is wrong.
CSP decides which origins may load scripts, frames, styles and connections. A policy that does not enumerate every third-party origin a page uses will block the loading of whatever is missing. What makes it expensive is the failure mode: a blocked script usually produces a console error for the one visitor who opens developer tools, and nothing at all in any dashboard you are watching.
For a site that runs third-party scripts — analytics, ad units, embedded media — that means a CSP written from memory will silently blank out something you are relying on. Ad units are the classic casualty: the slot renders empty, the network reports no error, and revenue is simply lower than it should be.
If you want the protection, there is a safe order:
- Start with
Content-Security-Policy-Report-Only. Nothing is blocked; violations are reported instead. The browser console is enough to read them if you do not want to stand up a reporting endpoint. - Browse the site with the console open. Note every origin that shows up.
- Enumerate them explicitly —
script-src,style-src,img-src,connect-src,frame-srcare the ones that matter for most sites. - Only then switch the header name from
-Report-Onlyto the enforcing version.
If you are not going to do those four steps, leaving CSP off is a defensible choice. An unenforced header and a broken one are not equally bad.
Two headers that are not security but belong in the same file
Cache control for fingerprinted assets. A stylesheet whose filename contains a content hash can be cached indefinitely, because the filename changes whenever the content does:
/assets/*.css
Cache-Control: public, max-age=31536000, immutable
Do not apply the same policy to HTML. Pages need to reflect the newest deploy.
X-Robots-Tag: noindex on preview URLs. Every deployment gets a preview address on *.pages.dev. Those are publicly reachable and indexable unless you say otherwise:
https://:project.pages.dev/*
X-Robots-Tag: noindex
The :project segment is a placeholder Pages substitutes at request time. Without this, search engines can end up with duplicate copies of your content on a domain you will never look at again.
The limit that fails quietly
Cloudflare caps _headers at 100 header rules, with a 2000-character limit per line — spacing and header name included. Go over either ceiling and the file is rejected, and the symptom is not an error message. Your deployment succeeds, the site serves normally, and none of your headers are applied.
A second constraint worth knowing before you lean on this file: headers defined in _headers are not applied to responses generated by Pages Functions. The moment you add a functions/ directory or an advanced-mode _worker.js, those responses need their headers attached in code instead. A purely static site is unaffected — but a project that starts static and later grows a few dynamic routes will lose its headers on exactly those routes, silently.
That is why the last step is always the same:
curl -sI https://your-domain.com/ | grep -i -E "x-content-type|referrer-policy|x-frame|permissions-policy"
If headers you wrote are missing from that output, check the file’s size and rule count before you check anything else. Then confirm the file is in the build output — a _headers file sitting in the project root rather than the published directory is a common mistake, and it produces exactly the same silence.
The short version
Seven lines of configuration, one command to verify, and a deliberate decision about CSP rather than a default. Set the headers you understand, leave out the one you do not, and check the response once after the first deploy. It is a twenty-minute task that most sites never do.
Written by TestedHost. Every recommendation on this site comes from running the setup described, on a live deployment — not from a vendor spec sheet. Spotted something out of date? Tell us.