Five security headers my static site still sends
This site is about as simple as a website gets: a folder of pre-built HTML, CSS and JavaScript served from a CDN. There is no login, no form, no database, nothing to steal. When I moved it to Cloudflare Pages I still added a small file that sends five extra HTTP headers with every response.
This post explains what those headers do, which attack each one stops, and why some of them only work as headers and not in the HTML. To get there, we start with what the browser actually receives when it loads a page.
What the browser actually receives
When you open https://rrodrigues.dev/about/, the browser sends a request and the server sends
back a response. A response has three parts:
HTTP/2 200
content-type: text/html; charset=utf-8
cache-control: public, max-age=0, must-revalidate
<!doctype html>
<html lang="en">
...
- A status line —
200means "here's what you asked for". - Headers —
name: valuepairs describing the response. - The body — the HTML itself.
Headers are metadata, and the browser reads them before it touches the body. Some describe the
content (content-type says "this is HTML"), some control caching, and some — the ones this
post is about — change how strict the browser is with your site.
You can see them yourself: open DevTools, go to the Network tab, reload, click the first request and look at Response Headers. Or from a terminal:
curl -sI https://rrodrigues.dev/
Browsers are permissive by default
The web has thirty years of backwards compatibility to protect. Browsers will happily load pages over plain HTTP, guess what type a file is, embed any site inside any other site, and tell every outbound link where the visitor came from — because changing any of those defaults would break millions of old pages.
Security headers are how a site says "I don't need the permissive behaviour — lock it down for me". The browser is the one enforcing them. The server only asks.
That framing matters: a security header doesn't protect your server. It protects your visitors, by making their browser refuse to do certain dangerous things on your site's behalf.
Why not just put them in the HTML?
HTML has <meta http-equiv="...">, which lets a page declare some header-like settings inline.
My pages already use it for a Content Security Policy:
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; ..."
/>
That policy tells the browser to only load scripts, styles, fonts and images from my own domain.
If someone managed to inject <script src="https://evil.example/x.js"> into a page, the browser
would refuse to run it.
But a <meta> tag has a timing problem: it only exists once the browser has started parsing the
body. Some protections need to apply before that moment, or outside the page entirely:
- deciding whether to use HTTPS happens before the request is even sent;
- deciding whether a page may be embedded in someone else's frame is a decision about the parent page, which the child's HTML can't be trusted to make in time.
So the specs are explicit: browsers ignore those settings when they appear in a <meta>
tag. They only count when they arrive as real response headers. That is why static sites still
need a bit of host configuration.
The five headers
Here's the whole file, as Cloudflare Pages expects it:
/*
Strict-Transport-Security: max-age=31536000; includeSubDomains
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=()
Content-Security-Policy: frame-ancestors 'none'
Let's take them one at a time.
1. Strict-Transport-Security — never use plain HTTP again
The attack. Someone types rrodrigues.dev into the address bar. With no https://, the
browser tries plain HTTP first. The server answers with a redirect to HTTPS and everything is
encrypted from then on.
That first request is the weak spot. On a hostile network — a café Wi-Fi, a compromised router — an attacker can intercept it and simply never pass the redirect along. They talk HTTPS to the real site and plain HTTP to the victim, reading and rewriting everything in between. This is called SSL stripping, and the visitor sees nothing wrong except a missing padlock.
The fix.
Strict-Transport-Security: max-age=31536000; includeSubDomains
Once the browser has seen this header over a valid HTTPS connection, it remembers: for the next
max-age seconds (here, one year), never contact this domain over plain HTTP. Every
http:// link and typed address is upgraded to https:// inside the browser, before a single
byte goes over the network. The vulnerable first request no longer exists.
includeSubDomainsextends the rule to every subdomain —www.,blog., anything.- As a side effect, the browser also removes the "proceed anyway" button on certificate errors for your domain. If the certificate is wrong, the visitor can't click past it.
- The header is ignored if it arrives over plain HTTP. Otherwise an attacker could inject it.
The catch. HSTS is a promise you can't take back remotely. If any subdomain ever needs plain
HTTP, browsers that cached the header will refuse to reach it until max-age runs out. Before
turning it on, make sure every subdomain serves HTTPS. A cautious rollout starts with a short
max-age (say, 300) and raises it once nothing breaks.
Going further. HSTS still trusts the very first visit. To close that gap, browsers ship a
built-in preload list of domains that are HTTPS-only from day one. You can submit your domain
at hstspreload.org after adding a preload directive — but removal
from that list takes months, so treat it as permanent.
2. X-Content-Type-Options — don't guess file types
The attack. Every response says what it is through content-type: text/css,
image/png, text/javascript. Older browsers didn't fully trust that label. If something looked
like a script, they would sniff the contents and treat it as one — a feature called
MIME sniffing.
Attackers exploited it. Upload a "profile picture" that is really JavaScript to a site that
accepts images, then reference it from a <script> tag. A sniffing browser would run it with
the site's full privileges.
The fix.
X-Content-Type-Options: nosniff
The browser trusts the declared content-type and stops guessing. It also refuses to execute a
script or apply a stylesheet if the server labelled it as anything else.
For a static site, the risk is small — there are no uploads, and the host labels files correctly. It's here because it costs nothing and turns a whole class of mistakes into loud errors instead of silent vulnerabilities.
3. Referrer-Policy — don't leak where visitors came from
The problem. When you click a link from one site to another, the browser sends a Referer
header (yes, misspelled in the spec since 1996) telling the destination where you came from.
Historically, that was the full URL — path, query string and all. URLs carry more than people
think: search terms, internal IDs, sometimes password-reset tokens.
The fix.
Referrer-Policy: strict-origin-when-cross-origin
Translated:
| Navigation | What gets sent |
|---|---|
| Within my own site | The full URL |
| To another site over HTTPS | Only https://rrodrigues.dev |
| From HTTPS to plain HTTP (a downgrade) | Nothing |
Other sites still learn that a visitor came from my site, which is useful and harmless. They don't learn which page.
For a static site, this is mostly about being explicit. Modern browsers already use this exact policy by default; setting it pins the behaviour down for older ones and documents the decision.
4. Permissions-Policy — turn off features you don't use
The problem. Pages can ask for powerful browser features: camera, microphone, location, and more. The permission prompt shows the site's name — "rrodrigues.dev wants to use your camera". If a script that shouldn't be there ever ran on my page — a compromised dependency, an embedded widget gone wrong — it could make that request with my name on it.
The fix.
Permissions-Policy: camera=(), microphone=(), geolocation=()
The empty list () means allowed for nobody — not this page, not any frame embedded in it. The
browser blocks the API outright; no prompt is ever shown. The visitor never has to make a
decision because there is nothing to decide.
For a static site, this is a safety net. My site doesn't use any of these features, so disabling them costs nothing and removes a way my domain could be abused.
5. Content-Security-Policy: frame-ancestors — don't let others embed you
The attack. It's called clickjacking. An attacker builds a page that loads your site in
an invisible <iframe>, positioned exactly underneath a harmless-looking button: "Click to claim
your prize". The victim clicks what they see, but the click lands on your site — where they
might be logged in. Depending on the site, that click could change a setting, confirm a payment,
or delete an account.
The fix.
Content-Security-Policy: frame-ancestors 'none'
The browser will refuse to render the page inside a frame on any site, including my own. The
older header X-Frame-Options: DENY does the same thing, and you'll still see it in the wild.
This is the clearest example of why <meta> isn't enough. By the time the browser could read a
<meta> tag, it's already loading the page inside the attacker's frame, so the spec says
frame-ancestors in <meta> is ignored. It has to be a header.
What about the other CSP? Now the page has two policies: the <meta> one restricting where
resources load from, and this header restricting where the page can be embedded. They don't
conflict. The browser enforces every policy it receives, and a request must satisfy all of
them, so policies can only make things stricter.
For a static site, there's nothing to hijack — no session, no actions. What it does prevent is someone wrapping my site inside theirs and passing it off as their own.
Sending them
On Cloudflare Pages, headers live in a plain-text file named _headers in the output folder.
In a Vite project, putting it in public/ is enough — Vite copies that folder into dist/
untouched.
/*
Strict-Transport-Security: max-age=31536000; includeSubDomains
...
- The unindented line is a URL pattern.
/*matches every path. You can add more blocks for specific paths, like/assets/*. - The indented lines are headers added to every response that matches.
- Pages reads the file at deploy time. It isn't served to visitors.
Every host has its own version of this: vercel.json on Vercel, netlify.toml or a _headers
file on Netlify, server config on nginx or Apache. The headers are the same; only the file
changes.
A note on HSTS specifically: Cloudflare also offers it as a dashboard toggle that applies to the whole domain. I kept it in the file instead, so all five headers live in version control, get reviewed in the same pull request as everything else, and show up on preview deployments before they reach production.
Checking they work
After deploying:
curl -sI https://rrodrigues.dev/ \
| grep -iE 'strict-transport|nosniff|referrer-policy|permissions-policy|security-policy'
Each of the five should come back. For a second opinion, scanners like Mozilla Observatory and securityheaders.com grade a site's headers and explain anything missing.
Was it worth it?
Honestly, for a static site with no logins, the risk each header removes is small. Ranked by how much they matter here:
- HSTS — real protection for every visitor on a network they don't control.
frame-ancestors— stops my site from being framed and impersonated.- The other three — defence in depth. They cost nothing today, and they're already in place if the site ever grows a form, a login, or a dependency I shouldn't have trusted.
The setup was one six-line file. Like most security basics, it's cheap to get right on day one and annoying to retrofit after something goes wrong.