Server recipe
A complete server block you can paste, a line-by-line explanation of what you just pasted, and the one nginx behaviour that quietly undoes the whole thing.
01 Where it goes
Put the directives in the server block that serves your site, next to your TLS configuration. They then apply to every response from that virtual host, including static files and error pages, which is what you want: a security header that is missing from your 404 page is missing from a page attackers can reach.
The trap is inheritance. nginx inherits add_header directives from an outer block only as long as the inner block defines none of its own. Add a single add_header inside a location and every header from the server block stops applying there, without a warning and without an error in the configuration test. If you need a header in one location, repeat all of them.
02 The recipe
A safe starting point for a site that serves its own assets. The always flag matters: without it nginx sends the header only on a handful of successful status codes and leaves your redirects and error responses bare.
server { listen 443 ssl; server_name example.com; add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
add_header Content-Security-Policy "default-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(), camera=(), microphone=(), payment=(), usb=()" always; } Two adjustments before this is yours. The Content-Security-Policy above allows nothing from third parties, so if you embed maps, fonts or analytics it will block them, and you should roll it out via Content-Security-Policy-Report-Only first. And leave preload out of the HSTS header until you have read what it commits you to.
03 Line by line
Every directive from the block above on its own, so you can leave out the ones that do not fit your site rather than pasting something you cannot explain.
Strict-Transport-Security What it does. Commits browsers to reaching your site over HTTPS only, for two years. Do not add this until HTTPS works everywhere on the domain, including every subdomain, because includeSubDomains applies to hosts you may have forgotten.
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always; Content-Security-Policy What it does. Restricts every resource to your own origin and forbids framing entirely. This is the line most likely to break something on a real site, and the only one you should roll out in report-only mode first.
add_header Content-Security-Policy "default-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
add_header Content-Security-Policy-Report-Only "default-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; report-uri /csp-report" always; X-Frame-Options What it does. Stops your pages being embedded in a frame anywhere. Use SAMEORIGIN instead if part of your own site frames another part.
add_header X-Frame-Options "DENY" always; X-Content-Type-Options What it does. Tells the browser to trust your declared content types rather than guessing. There is no site where this value is wrong, so paste it and move on.
add_header X-Content-Type-Options "nosniff" always; Referrer-Policy What it does. Sends the full URL within your own site and only the bare domain to anyone else. This is the value to set for almost every public website.
add_header Referrer-Policy "strict-origin-when-cross-origin" always; Permissions-Policy What it does. Switches off camera, microphone, geolocation, payment and USB access for your pages and anything they embed. Extend the list rather than shortening it: features you do not name stay available.
add_header Permissions-Policy "geolocation=(), camera=(), microphone=(), payment=(), usb=()" always; 04 Verifying it
nginx -t catches a syntax error but says nothing about whether a header actually reaches a browser, and the inheritance trap above produces a configuration that tests clean and serves nothing. Reload, then look at a real response.
nginx -t && systemctl reload nginx curl -sI https://example.com | grep -i "^strict-transport\|^content-security\|^x-frame" Check a URL inside a location block as well as the front page, and check a 404. Those are the two places where headers disappear without anything looking wrong.
05 Questions
Almost always the inheritance rule: some location block further in defines its own add_header, which discards every inherited one. Search your configuration for add_header and repeat the full set in any block that has one.
Without it, nginx adds the header only on a short list of successful responses. Redirects, 404s and 5xx pages go out without it, and those are exactly the responses an attacker is most likely to be looking at.
You can, and it is tempting when you host several sites. The same inheritance rule still applies at each level, so a single add_header in one server block removes the whole set for that site. Setting them per server block is more repetitive and much harder to break by accident.
It can add its own, and you can end up with two values for the same header, which browsers handle inconsistently. If your application sends security headers too, decide on one layer and switch the other off rather than letting both run.
06 In depth
This page is the configuration. These guides are what each header actually does and how to choose its value.
What each response header does, which ones your site should send and how to configure them correctly.
The strongest defence against cross-site scripting: which directives to set, how to roll a policy out in report-only mode and how to reach an enforcing policy without breaking your site.
Strict-Transport-Security explained: what max-age, includeSubDomains and preload actually do, and why the preload list is a decision you cannot quickly undo.
Secure, HttpOnly and SameSite: the cookie attributes that keep a session out of reach of scripts and cross-site requests, with the settings for a typical stack.
How an invisible overlay turns a visitor click into an action on your site, and the two headers that stop it: X-Frame-Options for older clients, frame-ancestors for everything else.
Which part of your URLs travels to other sites when a visitor clicks away: the five policy values that matter, what each one gives up and the one to set by default.
Switch off camera, microphone, geolocation and payment for your site and everything it embeds: the allow-list syntax, and why a feature you did not name is not a feature you turned off.
The records that stop someone sending mail in your name, and the one that keeps your DNS answers honest: what each does and the order to introduce them in.
The file that tells a researcher where to report a vulnerability. Two mandatory fields, ten minutes of work, and the difference between a private report and a public one.
A header that is right today can be gone after the next deploy. Three ways to notice: a cron job with curl, a check in your CI pipeline and a scheduled scan, with what each of them catches and misses.
How a policy changes quietly through deploys, plugins and CDN rules, and how to catch it: a header diff, violation reports through report-to, and what a scan comparison can and cannot show.
Keep reading
Every guide stands on its own, and together they cover what our scanner looks at. Pick the one closest to your next question.
Our free Quickscan reads the headers from your live response rather than from your configuration file, and explains every finding in plain language.