Skip to content

Set nginx security headers correctly

Severity: highApplies to: nginx 1.25+Applies to: nginx 1.30 / 1.31Applies to: freenginx
The fix/etc/nginx/conf.d/security-headers.conf
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "frame-ancestors 'self'" always;
add_header Cross-Origin-Opener-Policy "same-origin" always;

HSTS has its own page — see Enable HSTS, because it is the one that is hard to undo.

The inheritance rule that breaks everything

Section titled “The inheritance rule that breaks everything”

This is the most important paragraph on the page, and it is the thing most nginx configs get wrong without knowing.

add_header directives are inherited from the enclosing block only if the current block defines no add_header directives of its own. They do not merge. A single add_header in a location replaces the entire inherited set.

So this config serves no security headers under /api/:

server {
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
location /api/ {
add_header X-Custom-Thing "value" always; # drops BOTH headers above
}
}

nginx does not warn. nginx -t passes. The headers are simply gone for that one path — usually the path handling the most sensitive traffic.

There are two honest fixes:

Put the headers in an include and repeat it wherever you add anything:

# /etc/nginx/snippets/security-headers.conf — then in each block:
include snippets/security-headers.conf;
add_header X-Custom-Thing "value" always;

Or use the headers-more module (more_set_headers), which does accumulate across levels. It is a third-party module, so it needs a package that bundles it (nginx-extras on Debian and Ubuntu) or a custom build.

The include approach needs no modules and is what most people should do. Just know that “I set it in the server block” is not an answer to “does this location send it”.

Without always, add_header applies only to a specific list of successful status codes (200, 201, 204, 301, 302, 303, 304, 307, 308).

That means your 404s and 500s ship no security headers. Error pages are frequently where reflected content and stack traces appear, so they’re precisely where you want nosniff and a frame policy. Add always to every security header, every time.

Nearly every nginx hardening guide still recommends this:

add_header X-XSS-Protection "1; mode=block"; # don't

It is deprecated, and the reason matters: the header enabled a browser-side XSS filter that researchers showed could be weaponised against the page it was meant to protect. By crafting a URL that made the filter misidentify a legitimate script as an attack, an attacker could selectively disable specific scripts on a target page — turning a defence into a tool for breaking your site’s own security controls. MDN is explicit that the feature “can create XSS vulnerabilities in otherwise safe websites”.

Browsers removed the filter. The header does nothing in anything current, and it is not a harmless leftover — it is a documented footgun. If your config has it, delete the line rather than leaving it for the scanner’s benefit. A real Content-Security-Policy is the replacement.

frame-ancestors in CSP is the standard, and it is strictly more capable: it takes a source list rather than one origin, and it applies to <frame>, <iframe>, <embed> and <object>.

Keeping X-Frame-Options: SAMEORIGIN alongside it is harmless and covers very old browsers. Just don’t set it instead of frame-ancestors, and don’t let the two disagree — where both are present, frame-ancestors wins in browsers that support it, which will confuse whoever debugs it later.