Articles › Security

Five security headers every site needs, and the nginx trap that drops them

Five headers that block basic web attacks, how to set them in nginx, and the add_header trap that silently drops them for some files, checked on a live site.

Beginner 5 min read October 16, 2026

What you will learn
  • Five headers take under ten minutes to add and close several basic attacks
  • In nginx, a location with its own add_header sends none of the outer ones; include them again
  • Check with curl on both pages and static files, because they usually live in different locations

HTTP headers are short lines the server sends with a page, telling the browser what to be careful about. Five of them take under ten minutes to add, yet close off several basic attacks. This article explains each one, shows the values this site really uses, and describes an nginx trap that drops headers for certain file types while the home page still looks perfectly fine.

Tested with nginx 1.24 on Ubuntu 24.04, written from the side of someone looking after their own site.

The five we use

This site keeps its headers in one file, /etc/nginx/snippets/sec-headers.conf:

add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header X-Frame-Options "DENY" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
add_header Strict-Transport-Security "max-age=31536000" always;
HeaderProtects against
X-Content-Type-Options: nosniffthe browser guessing a file’s type, such as running an uploaded “image” as a script
Referrer-Policyour full URLs (which may carry data in the query) leaking to other sites when users follow a link out
X-Frame-Options: DENYanother site embedding our page in an invisible iframe to trick clicks (clickjacking)
Permissions-Policyscripts on the page, including third-party ones, asking for the camera, microphone or location
Strict-Transport-Securityusers being downgraded to plain HTTP on their next visit

The word always at the end matters. Without it nginx adds headers only to successful responses, so 404 and 500 pages get none.

The ones people ask about

Referrer-Policy: strict-origin-when-cross-origin sends the full URL for links within the site, only the domain for links to other sites, and nothing at all when going from HTTPS to HTTP.

HSTS: max-age=31536000 is one year. Once a browser has seen this header, for the next year it rewrites http:// to https:// before sending anything, so a user typing the name without https cannot be intercepted on the way. We have not added preload yet, because it is hard to undo; every subdomain must be permanently on HTTPS first.

X-Frame-Options: modern browsers have a finer tool, Content-Security-Policy: frame-ancestors 'none', but X-Frame-Options is still widely supported and simple. Using both is fine.

The trap: add_header is not inherited

nginx has one rule that catches a great many people:

If a location has even one add_header of its own, none of the add_header lines from the server level are sent for that location.

Here is an example that looks right:

server {
    include snippets/sec-headers.conf;   # at the outer level, assumed to cover everything

    location / {
        try_files $uri $uri/ =404;       # gets every header
    }
    location ~* \.(css|js|png|svg)$ {
        add_header Cache-Control "public"; # has its own add_header
        # → all five security headers vanish for this location
    }
}

The home page gets every header, so checking the home page passes, yet every CSS, JS and image file has no security headers at all. The fix is to include them again in every location that has its own add_header:

location ~* \.(css|js|png|svg)$ {
    include snippets/sec-headers.conf;
    add_header Cache-Control "public";
}

That is why we keep the headers in a snippet file instead of writing them straight into the server block: one line re-includes them, and one edit changes them everywhere.

server { include sec-headers }location /no add_header of its own✓ all sentlocation ~* \.(css|js)$add_header Cache-Control✗ all lostlocation ~* \.(css|js)$include sec-headers + Cache-Control✓ all sent
A location with its own add_header inherits none from outside; include them again

The second trap: regex locations win

This site is built with Astro. Files under /_astro/ change name whenever their content changes, so they can be cached for a year. But written like this:

location /_astro/ { expires 1y; }
location ~* \.(css|js)$ { expires 30d; }

/_astro/index.css gets 30 days, not a year, because nginx prefers a regex location over a plain prefix location. Adding ^~ says “if this matches, stop and do not check the regexes”:

location ^~ /_astro/ {
    include snippets/sec-headers.conf;
    expires 1y;
    add_header Cache-Control "public, immutable";
}

Check the real thing

Do not check only the home page; check static files too, since they usually sit in a different location:

curl -sI https://6amdev.com/ | grep -iE "strict|x-frame|x-content|referrer|permissions"
curl -sI https://6amdev.com/_astro/index.xB_X6hmL.css | grep -iE "x-frame|cache-control"

While writing this article, checking a file under /_astro/ showed us something small we had never noticed:

Cache-Control: max-age=31536000
Cache-Control: public, immutable

Two Cache-Control lines, because expires produces one and add_header adds another. Browsers merge them into one value, so it still works, but a single line replacing both is tidier:

add_header Cache-Control "public, max-age=31536000, immutable";

That is the value of checking real headers rather than reading the config file.

Next step: Content-Security-Policy

The most powerful header is CSP, which tells the browser where scripts, styles and images may come from. If someone manages to inject a script (XSS), a good CSP stops it from running.

CSP is also the easiest way to break a site, because you need to know everything the site loads and from where. The safe way is to start in report-only mode:

add_header Content-Security-Policy-Report-Only "default-src 'self'; img-src 'self' data:; style-src 'self' https://fonts.googleapis.com; font-src https://fonts.gstatic.com" always;

Open the browser console, see what gets reported, adjust until nothing is, then switch to the real Content-Security-Policy.

Common mistakes

  • Leaving out always, so error pages have no headers
  • Setting headers at the server level while a location has its own add_header, so static files lose them
  • Checking only the home page
  • Adding HSTS preload while some subdomain still uses HTTP
  • Turning on a real CSP straight away without report-only mode, then losing fonts or analytics across the whole site
  • Reloading without running sudo nginx -t first; with several sites on one machine, one broken config takes them all down

FAQ

Will adding headers break my site?

The five here rarely break an ordinary site, except X-Frame-Options DENY if your pages must be embedded in another site's iframe. Content-Security-Policy is far more likely to break things, so start it in report-only mode.

What is HSTS preload, and should I use it?

Preload asks browsers to build your domain in as HTTPS-only from the very first visit. It is slow and hard to undo, so add it only once every subdomain is permanently on HTTPS.

Commands and settings were tested on sample systems. Try them on a test machine before production.