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">
  ...
  1. A status line — 200 means "here's what you asked for".
  2. Headers — name: value pairs describing the response.
  3. 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.

  • includeSubDomains extends 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:

  1. HSTS — real protection for every visitor on a network they don't control.
  2. frame-ancestors — stops my site from being framed and impersonated.
  3. 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.