[GUIDE]

Nginx Security Headers: A Copy-Pasteable Config and Verification Guide

Seen enough? Run a free scan on your own site.Try it now →

If you want the short answer: put add_header directives in your server { } block (or http { } for every site), give each one the always flag, set the six headers that matter — HSTS, Content-Security-Policy, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, and Permissions-Policy — turn off server_tokens, then run nginx -t and nginx -s reload. After that, verify what your deployed site actually returns with a security headers checker, because nginx has two quiet traps (the always flag and add_header inheritance) that make headers vanish from real responses even when they look right in your config.

This guide covers each header, a sane starting value, exactly where to put it in nginx, the two gotchas that cause most "I set it but the scan says it's missing" reports, and how to confirm it all works.

Which security headers does nginx need to send?

Nginx does not add any of these headers by default — every one is opt-in via add_header. Here's the practical set, what each does, and a starting value you can refine. Each header links to its MDN reference; the directive itself is documented in the nginx ngx_http_headers_module docs.

HeaderWhat it doesRecommended starting value
Strict-Transport-SecurityForces HTTPS, blocking protocol-downgrade and SSL-strip attacksmax-age=31536000; includeSubDomains
Content-Security-PolicyControls which sources can load scripts, styles, frames, etc. — your main XSS defensedefault-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' (tune per app)
X-Frame-OptionsStops your pages being embedded in frames (clickjacking)DENY (or SAMEORIGIN)
X-Content-Type-OptionsDisables MIME sniffing; the only valid value is nosniffnosniff
Referrer-PolicyLimits how much URL data leaks to third partiesstrict-origin-when-cross-origin
Permissions-PolicyScopes powerful browser features (camera, mic, geolocation)camera=(), microphone=(), geolocation=()

A few notes before you copy values blindly:

  • HSTS preload is hard to undo. max-age=31536000 (one year) is a solid starting point; 63072000 is two years. Only add includeSubDomains and preload once every subdomain serves HTTPS — preload is hard to reverse. The OWASP Secure Headers Project and the HSTS spec, RFC 6797, both stress that a long max-age is what actually buys protection.
  • CSP is the one header you must tune per app. Start strict and loosen as needed — a default-src 'self' policy breaks any third-party script, analytics, or font CDN until you allow it explicitly. The starting value above includes 'unsafe-inline' for styles only; see the CSP section for the tradeoff.
  • frame-ancestors supersedes X-Frame-Options in modern browsers. Keeping both X-Frame-Options: DENY and CSP frame-ancestors 'none' is fine for older clients.

Where do I put add_header in nginx config?

Put add_header in the server { } block to cover one virtual host, or in the http { } block to apply it across every site. The directive can also go inside a location { } block — but that's exactly where the inheritance trap (below) bites, so prefer the server level unless you have a reason not to.

After any change, always validate and reload — never edit and pray:

nginx -t          # test the config for syntax/semantic errors
nginx -s reload   # apply it gracefully, no dropped connections

If nginx -t fails, the reload won't ship a broken config — fix the error first.

How do I add all the security headers in nginx? (full config)

Here is a complete, copy-pasteable server { } block with every header from the table, plus server_tokens off; to stop nginx from advertising its version. Each add_header uses the always flag — read the next section to understand why that flag is non-negotiable for security headers.

server {
    listen 443 ssl;
    server_name your-domain.com;

    # --- TLS config (certs, protocols) goes here ---

    # Hide the nginx version from Server: and error pages
    server_tokens off;

    # Force HTTPS for one year (use 63072000 for two years).
    # Only add "includeSubDomains; preload" once EVERY subdomain serves HTTPS.
    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;

    # Your main XSS defense. Start strict, loosen per app.
    # 'unsafe-inline' on style-src is a tradeoff — see the CSP section.
    add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'" always;

    # Clickjacking protection. CSP frame-ancestors supersedes this in modern browsers.
    add_header X-Frame-Options "DENY" always;

    # Disable MIME sniffing.
    add_header X-Content-Type-Options "nosniff" always;

    # Limit referrer leakage to third parties.
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;

    # Scope powerful browser features off by default.
    add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;

    location / {
        # IMPORTANT: if you add ANY add_header here, you MUST re-declare
        # all six headers above, or nginx silently drops the inherited ones.
        # See the inheritance gotcha section.
        try_files $uri $uri/ =404;
    }
}

To apply these site-wide instead of per-host, move the add_header lines into the http { } block — same directives, same always flag, same inheritance rules.

Why does the scan say a header is missing when I set it in nginx?

This is the single most common nginx headers question, and there are two distinct causes. Both are real, well-documented behaviors of add_header — not bugs — and both make a header that looks correct in your config disappear from actual responses.

1. The always flag — headers vanish on error pages

By default, nginx only emits add_header directives on responses with a "successful" status: 200, 201, 204, 206, 301, 302, 303, 304, 307, and 308. Every other response — your 404 pages, 403 denials, 500 errors — ships without the headers.

That matters because error pages are still real responses a browser renders, and a missing HSTS or X-Content-Type-Options on them is a genuine gap. It also trips scanners: a checker that lands on a 404 or a redirect-then-error path will correctly report the header as absent.

The fix is the always parameter, documented in the ngx_http_headers_module reference. With always, nginx emits the header on every response regardless of status:

add_header X-Content-Type-Options "nosniff" always;

Use always on every security header. There is no downside for these headers, and forgetting it leaves your error responses unprotected.

2. The inheritance trap — a child block drops ALL inherited headers

This is the bigger and more surprising one. Nginx's add_header directives are inherited from a parent block (httpserverlocation) only if the child block does not define any add_header of its own. The moment a location (or server) block declares even a single add_header, nginx replaces the entire inherited set with just that block's directives — silently dropping every header from the parent.

So this config:

server {
    add_header X-Frame-Options "DENY" always;
    add_header X-Content-Type-Options "nosniff" always;

    location /api/ {
        add_header Cache-Control "no-store" always;   # <-- drops the two above!
    }
}

…sends X-Frame-Options and X-Content-Type-Options everywhere except /api/, where only Cache-Control ships. This is the number-one reason "I set the header at the server level but the scan says it's missing on /some/path" happens on nginx.

The fix is to re-declare every header you need in any block that introduces its own add_header:

    location /api/ {
        add_header X-Frame-Options "DENY" always;
        add_header X-Content-Type-Options "nosniff" always;
        add_header Cache-Control "no-store" always;
    }

Because this behavior is per-block and easy to miss across a large config, the only reliable way to know your headers reach every route is to test the live response on the actual paths — which is what the verification step below does.

How do I set a Content-Security-Policy in nginx?

CSP is the one header you genuinely have to tune; everything else has a safe universal value, but a CSP that's right for one app breaks another. The principle: start strict, then loosen as the browser console tells you what you're blocking.

A conservative starting policy:

add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'" always;

This allows scripts, styles, images, and everything else only from your own origin — with one deliberate exception: 'unsafe-inline' on style-src. Here's the tradeoff to understand:

  • 'unsafe-inline' weakens CSP's XSS protection. It tells the browser to trust inline <style>/style= (or, if you add it to script-src, inline <script>), which is exactly the surface CSP exists to lock down. Many frameworks inject inline styles, so allowing it on style-src is a common pragmatic compromise; allowing it on script-src largely defeats the point.
  • The strict alternative is nonces or hashes. To block inline injection properly, generate a per-request nonce or hash each inline block and reference it in the policy. In nginx that means computing the nonce upstream (in your app) and passing it through, since a static config file can't generate a fresh value per request. For framework-level nonce setups, see the MDN CSP guide.

Whatever you ship, treat the CSP value as something you iterate on — watch the browser console for violation reports and tighten from there. And note that CSP frame-ancestors 'none' inside the policy supersedes the separate X-Frame-Options header in modern browsers, so the two together are belt-and-suspenders, not redundant.

How do I verify my nginx security headers are correct?

Setting headers in config is half the job. The browser only enforces what your deployed site actually returns — and on nginx, the always flag and the inheritance trap mean your config and your real responses can diverge silently. A header scoped to the wrong block, an error page without always, or a location that dropped its inherited headers all pass nginx -t cleanly and still fail in production.

Quick local check with curl:

curl -sI https://your-domain.com | grep -iE 'strict-transport|content-security|x-frame|x-content-type|referrer-policy|permissions-policy'

curl -I issues a HEAD request and prints the response headers. Run it against a few different paths — including one you expect to 404 — to catch the always and inheritance gaps, since those only surface on specific responses.

For a real verdict, run an external security headers checker against your live URL. SteelSuit's Security Headers Checker requests your deployed site, inspects the response headers it actually receives, and flags what's missing or weak — no install, no source-code access, nothing to configure. Concretely, the check:

  • Flags any missing header from the set above (HSTS, CSP, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy).
  • Acts as an HSTS checker — it parses your max-age and warns when it's shorter than six months, since a brief window leaves a downgrade gap.
  • Runs a CSP evaluator that flags 'unsafe-inline' and 'unsafe-eval' in script-src/default-src, wildcard (*) sources in critical directives, and missing directives.

Crucially, because it reads the live response rather than your config, the checker catches the nginx add_header inheritance trap — the exact failure where a header is present in your server block but dropped by a location block, so it never reaches the browser on some routes. Config inspection alone can't surface that; reading the real response on the real path can.

How to fix nginx security headers when the scan flags them

If a scan reports missing or weak headers, the fix usually maps directly back to your nginx config:

  • Missing header entirely → add the add_header ... always; line to your server (or http) block. Run nginx -t, then nginx -s reload, then re-scan.
  • Header present on some pages, missing on others → an inner location/server block defines its own add_header and dropped the inherited ones. Re-declare every header in that block.
  • Header missing on 404/500 pages → you forgot the always flag. Add it to every security header.
  • HSTS too short → raise max-age to at least 31536000 (one year); 63072000 is a common two-year value.
  • CSP allows unsafe-inline in script-src → move to nonce- or hash-based CSP; keep 'unsafe-inline' only on style-src if your framework needs it.
  • CSP uses * → replace wildcards with the specific origins you load from.
  • Server: leaks the nginx version → set server_tokens off;.

For a broader pass beyond headers, see our web app security checklist. If you front nginx with a CDN, Cloudflare security headers covers how a proxy can add or override what your origin sends. The same add_header pattern translates conceptually to framework config — see Next.js security headers for the equivalent there. And to make sense of what a scan returns, our guide on reading a vulnerability scan report walks through the output.

Security headers are one of the highest-leverage, lowest-effort hardening steps for any nginx-served site — a handful of add_header lines, the always flag on each, one inheritance check, and a single verification scan close a whole class of clickjacking, downgrade, and injection gaps.

Frequently asked

How do I add security headers in nginx?

Add add_header directives to your server { } block (or http { } to apply site-wide), always with the always flag, then run nginx -t to test the config and nginx -s reload to apply. Set Strict-Transport-Security, Content-Security-Policy, X-Frame-Options, X-Content-Type-Options: nosniff, Referrer-Policy, and Permissions-Policy.

Why does nginx say my header is set but the scanner reports it missing?

Two nginx-specific reasons. First, without the always flag nginx only emits add_header on 2xx/3xx responses, so error pages ship without headers. Second, nginx add_header does not inherit into a location or server block that defines its own add_header — any child block with an add_header silently drops every inherited one. Re-declare all headers in that block, or use the always flag and check inheritance.

What does the always flag do in nginx add_header?

By default nginx only adds the header on responses with status 200, 201, 204, 206, 301, 302, 303, 304, 307, or 308. The always flag makes nginx emit the header on every response, including 4xx and 5xx error pages. Security headers should always use the always flag so that error responses are protected too.

Where do I put add_header in nginx config?

Put add_header in the server { } block to cover one virtual host, or in the http { } block to apply it to every server site-wide. You can also place it inside a location block, but be aware that a location block with its own add_header drops inherited ones. After editing, run nginx -t then nginx -s reload.

What is a good HSTS value for nginx?

A common starting value is max-age=31536000; includeSubDomains (one year); 63072000 is two years. Only add includeSubDomains and preload once every subdomain reliably serves HTTPS, because preload is hard to reverse. A short max-age provides weaker protection against protocol-downgrade attacks.

Do I still need X-Frame-Options if I set CSP frame-ancestors?

CSP frame-ancestors supersedes X-Frame-Options in modern browsers and is more flexible. Setting both X-Frame-Options DENY and frame-ancestors 'none' is a reasonable belt-and-suspenders choice for older clients, but frame-ancestors is the primary control going forward.