Security headers are the cheapest hardening you will ever ship. They are a
dozen lines in your nginx config, they cost nothing at runtime, and they close
whole classes of browser-side bugs: session hijacking over plaintext,
cross-site scripting, clickjacking, MIME confusion, referrer leaks, and a
third-party script quietly asking for your camera. A strict
Content-Security-Policy is the one that does the most work, but the boring
ones earn their keep too. And yet most static sites ship one or two, or think
they are covered because their CDN added X-Content-Type-Options for them.
This is the set I actually run on this blog, what each header defends against,
and the nginx trap that silently disables half of them the moment you touch a
location block.
What security headers actually defend against
A response header is a set of instructions the browser follows. It does not
stop your server being scanned, and it does not protect a client that ignores
it — curl reads Content-Security-Policy and shrugs. What headers do is
constrain what a compliant browser is allowed to do with your pages and
content once it has them. That makes them defense in depth: when your app has a
bug, the browser refuses to turn that bug into an exploit.
Two rules before the list:
- A header protects users of your site, not your site. Nothing here makes your server harder to attack. That is server hardening, firewalls, and patching.
- Headers are additive across layers. If you are behind a CDN or reverse
proxy, decide once where each header is set, or you will end up with two
Content-Security-Policyheaders, and browsers apply the intersection — which is usually the strictest and most surprising combination.
HSTS: Strict-Transport-Security
HSTS tells the browser: for the next max-age seconds, never speak plain HTTP
to this host again — rewrite http:// links, refuse certificate-error
click-throughs. It defends against SSL stripping, where an attacker on the
path downgrades your first request to HTTP and steals the session before TLS
ever starts. Without HSTS, the browser has no memory of your HTTPS preference,
so every fresh visit is downgradeable.
add_header Strict-Transport-Security "max-age=31536000" always;
That is a reasonable starting point: one year, this host only. Two options make it powerful, and both are footguns on a first deploy.
includeSubDomainsapplies the policy to every subdomain. The instant a browser sees it, any subdomain that cannot serve a valid HTTPS certificate becomes effectively unreachable — the browser will not fall back to HTTP. Ifdev.example.comor an oldmail.example.comonly does HTTP, you just broke it for anyone who visited your main site. Audit your subdomains first.preloadasks browsers to ship your domain in a hardcoded list, so even the first visit is HTTPS-only. It also requiresincludeSubDomainsand amax-ageof at least one year. Removal is not instant: you submit a request and wait months for browsers to ship a new list. Treat preload as a one-way door.
My advice: ship max-age alone, watch the logs, and only add
includeSubDomains once you have proven every subdomain serves HTTPS. Add
preload last, deliberately, and submit at hstspreload.org when you are sure.
If you are still fighting certificate errors, fix those first — see
TLS errors explained.
Content-Security-Policy: start strict, loosen with evidence
CSP is the header that does the heavy lifting. It is an allowlist of where
the browser may load scripts, styles, fonts, images, and frames from, and what
it may connect to. The point is not to trust your markup; it is to make an
injected <script> fail. A cross-site scripting bug that would run attacker
code runs into script-src and dies.
The mistake is starting loose, because loose CSP is security theater.
script-src 'unsafe-inline' 'unsafe-eval' * allows everything and stops
nothing. Start from a policy a no-JS static site can actually satisfy:
add_header Content-Security-Policy "default-src 'none'; \
base-uri 'none'; form-action 'none'; frame-ancestors 'none'; \
img-src 'self' data:; style-src 'self'; font-src 'self'; \
manifest-src 'self'; connect-src 'none'; \
script-src 'none'; object-src 'none'; upgrade-insecure-requests" always;
Read it as: nothing by default; no <base> rewriting, no forms, no framing;
images, styles, fonts, and the web manifest only from my own origin (plus
data: images, which many sites need); no fetch/XHR; and crucially no
scripts at all. If your site truly ships no JavaScript, script-src 'none'
is the strongest single line in this post — an injected script cannot run, full
stop.
If you do ship JavaScript, add its origin and stop guessing:
# Astro's own island bundles, served from the same origin
script-src 'self';
For any inline script you cannot move to a file, use a nonce or a hash
rather than 'unsafe-inline'. Modern frameworks can emit nonces per request;
if yours cannot, hash the inline block and list it in script-src. Deploy in
two steps: first Content-Security-Policy-Report-Only (browsers report
violations without enforcing), read the reports, then flip the same string to
the enforcing header. Also note that CSP with a sandbox or
frame-ancestors cannot be delivered in a <meta> tag — it has to be a real
HTTP header.
X-Content-Type-Options: nosniff
Browsers try to be helpful: if a response is labelled text/plain but the bytes
look like HTML, some will “sniff” and render it anyway. That turns an innocuous
user-uploaded .txt into stored XSS. nosniff forbids the guess.
add_header X-Content-Type-Options "nosniff" always;
Pair it with correct Content-Type on every response — nosniff makes a
missing or wrong type fail loudly instead of being guessed at, which is what
you want.
Referrer-Policy
The Referer header leaks where a visitor came from, including full URLs with
tokens or query strings, to every third party the page touches. Referrer-Policy
decides how much is sent.
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
That default sends the full URL for same-origin requests and only the bare
origin (https://example.com/) cross-origin, and nothing at all over a
downgrade from HTTPS to HTTP. It is the sensible modern default.
no-referrer is stricter and breaks nothing except sites that rely on referrer
for analytics or hotlink protection; use it if you want zero leakage.
X-Frame-Options and CSP frame-ancestors
Clickjacking is framing your page invisibly under an attacker’s UI so a click
lands on your “delete account” button. Two headers stop it. X-Frame-Options
is the old one; frame-ancestors in CSP is the modern, expressive one.
add_header X-Frame-Options "SAMEORIGIN" always;
# frame-ancestors 'none' is already set in the CSP above
Use SAMEORIGIN if you frame your own pages, DENY if you never do.
frame-ancestors supersedes it in modern browsers and lets you allow specific
origins; keep X-Frame-Options as a belt for older ones. Do not set both to
conflicting values, or you get the surprise intersection again.
Permissions-Policy
Powerful browser features — camera, microphone, geolocation, and more — are on
unless you turn them off. Permissions-Policy (formerly Feature-Policy) is
how you turn them off, both for your own page and for anything you embed.
add_header Permissions-Policy "geolocation=(), camera=(), microphone=(), payment=(), usb=()" always;
For a content blog the honest answer is to deny everything you do not use — it
is a one-line guarantee that no script, yours or injected, can reach the
sensors. If you embed a map or a video player, allow only the specific
origin: geolocation=(self "https://maps.example.com").
CORS: what it is and what it isn’t
CORS is the most misunderstood item here, so be blunt about it: it is a
browser rule, not a server access-control mechanism.
Access-Control-Allow-Origin does not protect your data. curl, a script, or
a server can read your endpoint no matter what you set. What CORS does is tell a
browser whether JavaScript on another origin may read the response it
made. It is a relaxation mechanism, not a wall.
A static site usually needs CORS for exactly nothing — same-origin assets need no header. You touch it when a font, a JSON API, or a widget is loaded cross-origin. Two things to get right:
Access-Control-Allow-Origin: *andcredentials: includeare mutually excluded; if you need cookies, you must name the exact origin and addAccess-Control-Allow-Credentials: true. Never echo back arbitrary origins from the request.- Non-simple requests trigger a preflight
OPTIONSrequest; your server must answer it (Access-Control-Allow-Methods,Access-Control-Allow-Headers) or the real request never happens.
If you want to genuinely restrict who can read data, that is authentication and authorization, not CORS.
The nginx add_header inheritance trap
Here is the trap that makes people think headers “don’t work”. In nginx,
add_header inherits from the outer block only if the inner block has no
add_header of its own. The moment you add a single add_header inside a
location, it cancels every add_header inherited from server in that
location.
server {
add_header Strict-Transport-Security "max-age=31536000" always;
add_header X-Content-Type-Options "nosniff" always;
location /assets/ {
# This single line wipes HSTS and nosniff for /assets/*.
add_header Cache-Control "public, max-age=31536000, immutable";
}
}
Requests to /assets/* get Cache-Control and none of the security
headers. Nothing warns you; it just silently drops them. The same applies one
level deeper in nested location blocks, and it is the number-one reason a
securityheaders.com scan comes back with an A on / and a D on some asset.
The fix is to stop repeating yourself: put the security headers in a snippet and
include it in every block that defines its own add_header.
# /etc/nginx/snippets/security-headers.conf
add_header Strict-Transport-Security "max-age=31536000" always;
add_header Content-Security-Policy "default-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'; img-src 'self' data:; style-src 'self'; font-src 'self'; manifest-src 'self'; connect-src 'none'; script-src 'none'; object-src 'none'; upgrade-insecure-requests" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Permissions-Policy "geolocation=(), camera=(), microphone=(), payment=(), usb=()" always;
server {
server_name example.com;
server_tokens off;
include snippets/security-headers.conf;
location /assets/ {
include snippets/security-headers.conf; # re-include, or you lose them
add_header Cache-Control "public, max-age=31536000, immutable";
}
}
The always parameter matters too: without it, nginx adds a header only on
2xx, 3xx, 204 and 304 responses, so your security headers vanish on a 404 or
500 — exactly when an attacker probing your error pages would like them gone.
include at every level plus always is the whole fix. One snippet, no drift.
How to test it
Check with curl first, because it tells you the truth without a browser:
curl -sI https://example.com | grep -iE \
'strict-transport|content-security|x-content-type|referrer|x-frame|permissions'
# test a 404 and a nested asset too — the add_header trap hides there
curl -sI https://example.com/assets/app.js | grep -iE 'strict-transport|x-content-type'
curl -sI https://example.com/does-not-exist | grep -iE 'strict-transport'
Then use the scanners as a second opinion: securityheaders.com and Mozilla
Observatory grade the headers on a URL, and hstspreload.org will tell you
whether your HSTS is preload-eligible. Finally, open devtools → Console with a
CSP in Report-Only mode and click through your own site: any blocked inline
script shows up as a violation you can fix before enforcing.
Frequently asked questions
Do I need all of these on a static blog? HSTS, nosniff,
Referrer-Policy, frame-ancestors, and a strict CSP cover ~95% of the value.
Permissions-Policy is nearly free. CORS you probably do not need at all.
Will CSP break my site? Only if you enforce it before testing. Run Report-Only first, read the violations, then enforce the same policy.
Is HSTS preload worth the commitment? For a stable domain with HTTPS on every subdomain, yes. For anything experimental, no — removal takes months.
Why do my headers disappear on some paths? Almost always the add_header
inheritance rule above. curl -I the exact path and confirm.
Ship the snippet, verify with curl -I on more than one path, and move on. The
whole thing is an afternoon, once, and then it keeps working.