InfraRunBook
    Back to articles

    Nginx Security Headers Configuration

    Nginx
    Published: Apr 15, 2026
    Updated: Apr 15, 2026

    A practical guide to configuring HTTP security headers in Nginx, covering HSTS, CSP, X-Frame-Options, Permissions-Policy, and cross-origin isolation headers with real-world configuration examples and verification steps.

    Nginx Security Headers Configuration

    Prerequisites

    Before you start adding security headers to your Nginx configuration, make sure you're running Nginx 1.14 or later — that's where support for some of the more modern header directives is most reliable. On a fresh Debian or Ubuntu-based system, a standard

    apt install nginx
    will get you there. On RHEL or Rocky Linux derivatives, use the official Nginx repo rather than the distro package, which tends to lag behind by a minor version or two.

    You'll also want to know whether you're running Nginx as a pure reverse proxy or as a direct web server, because that changes where you add headers and what you might need to strip from upstream backends. For this walkthrough I'm using sw-infrarunbook-01 as the server hostname, serving the domain solvethenetwork.com, with an internal IP of 10.10.0.50.

    A few things to have in place before you start:

    • SSL/TLS already configured and working — HSTS in particular is meaningless without HTTPS, and enabling it prematurely can lock clients out
    • Root or sudo access on sw-infrarunbook-01
    • A clear picture of your
      server {}
      block layout — specifically whether you have nested
      location {}
      blocks that might interfere with header inheritance
    • curl available for verification, or browser developer tools you're comfortable reading

    Understanding What These Headers Actually Do

    Security headers are HTTP response headers that instruct browsers how to behave when loading your content. They're cheap to add and have no meaningful performance impact, but they're surprisingly easy to misconfigure. I've seen teams spend hours debugging a completely white page in production because someone pasted a Content-Security-Policy that blocked all inline scripts without realizing the application depended on them. Done right, these headers close real attack vectors. Done wrong, they either do nothing or actively break your site.

    The headers covered here are the ones that matter most in practice. Not every header from every OWASP checklist is useful in every context — I'll flag which ones you should always set versus which ones need careful thought and testing before you push them to production.


    Step-by-Step Setup

    Step 1 — Create a Dedicated Snippets File

    Don't dump all your security headers directly into your server block. It becomes unmanageable fast, especially when you're running multiple vhosts that should all share the same policy. Instead, create a dedicated include file. On most Nginx setups,

    /etc/nginx/snippets/
    is the right home for this kind of reusable configuration.

    sudo touch /etc/nginx/snippets/security-headers.conf
    sudo chown root:root /etc/nginx/snippets/security-headers.conf
    sudo chmod 644 /etc/nginx/snippets/security-headers.conf

    Once this file is built out, you include it in any server block that needs it with a single line. One place to update when a browser vendor deprecates something, one place to audit, one place to version-control.

    Step 2 — Strict-Transport-Security (HSTS)

    HSTS tells browsers to never make plain HTTP connections to your domain for a defined period. Once a browser sees this header, it will refuse to connect over HTTP even if the user explicitly types a plain URL. This is one of the most impactful headers you can set — and one of the most consequential to get wrong.

    Start conservative. Don't set

    includeSubDomains
    or
    preload
    on day one unless you're absolutely certain every subdomain is HTTPS-only. In my experience, the most common HSTS mistake is enabling
    includeSubDomains
    on a domain that still has internal tooling or staging environments running plain HTTP on a subdomain. Since HSTS is cached in the browser, removing the header doesn't immediately fix it — the client continues honoring the cached directive until
    max-age
    expires.

    add_header Strict-Transport-Security "max-age=31536000" always;

    Once you've confirmed everything is stable and all subdomains are HTTPS, extend it to the full recommended configuration:

    add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;

    The

    always
    parameter is critical. Without it, Nginx only sends the header on 2xx and 3xx responses. You want HSTS sent on error pages too — otherwise a browser that receives a 404 or 500 won't see the header and your enforcement has gaps.

    Step 3 — X-Frame-Options

    This header prevents your pages from being embedded in an iframe on another domain, which is the primary browser-level defense against clickjacking attacks. There are two meaningful values:

    DENY
    blocks all framing regardless of origin, and
    SAMEORIGIN
    allows framing only from the same origin.

    add_header X-Frame-Options "SAMEORIGIN" always;

    If you have no legitimate reason for your content to appear in an iframe anywhere — including from your own domain — use

    DENY
    . For most web applications
    SAMEORIGIN
    is the safer default. Note that this header is being superseded by the
    frame-ancestors
    directive in Content-Security-Policy, but you should still set both for compatibility with older browser versions that don't process CSP.

    Step 4 — X-Content-Type-Options

    This one is simple and has exactly one valid value. Set it and move on:

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

    It tells browsers not to guess the content type of a response and to trust the

    Content-Type
    header you send explicitly. MIME-type sniffing is how browsers historically executed uploaded files as scripts — a user uploads an image with embedded JavaScript, the browser sniffs it as JavaScript, and it runs. This header blocks that entire class of attack. It's the kind of header that seems trivial until it isn't.

    Step 5 — Content-Security-Policy

    CSP is the most powerful header here and also the one that requires the most thought. A bad CSP breaks your site. A missing CSP leaves you wide open to cross-site scripting. There's no magic universal default — it depends entirely on what resources your application actually loads and from where.

    Start with report-only mode. This sends the header without enforcing it, and browsers will log violations to the console without blocking anything:

    add_header Content-Security-Policy-Report-Only "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self';" always;

    Watch the browser console for CSP violation messages over a few days of real traffic. Once you're confident nothing legitimate is being flagged, switch to enforcing mode by replacing

    Content-Security-Policy-Report-Only
    with
    Content-Security-Policy
    :

    add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self';" always;

    The things I see broken most often:

    img-src
    that doesn't include
    data:
    for base64-encoded inline images, missing
    connect-src
    entries for WebSocket endpoints, and
    script-src
    policies that don't account for analytics or chat widgets loaded from external CDNs. Build your CSP from what your application actually does, not from what you think it does.

    Step 6 — Referrer-Policy

    The Referrer-Policy header controls how much information is included in the

    Referer
    header when users navigate away from your site. Default browser behavior leaks full URLs including query strings, which can expose session tokens, search terms, or other sensitive path components to every third-party resource your page loads.

    add_header Referrer-Policy "strict-origin-when-cross-origin" always;

    This value sends the full URL for same-origin requests — which keeps your own analytics intact — but only sends the bare origin for cross-origin requests, stripping the path and query string. It's a solid default that balances privacy with functionality. If you need maximum privacy and don't care about cross-origin referrer data at all, use

    no-referrer
    .

    Step 7 — Permissions-Policy

    Formerly called Feature-Policy, Permissions-Policy lets you control which browser APIs and hardware features your page and any embedded third-party content can access. This is your defense in depth against compromised or malicious third-party scripts that try to silently access the microphone, camera, or geolocation API.

    add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=(), interest-cohort=()" always;

    The empty parentheses syntax disables a feature entirely for the page and all embedded content. If your application genuinely needs geolocation, write

    geolocation=(self)
    to allow it only from your own origin. The
    interest-cohort=()
    entry opts out of Google's Topics API behavioral tracking — worth including on any site that values user privacy, since browsers may enroll your site automatically without it.

    Step 8 — Cross-Origin Isolation Headers

    These three headers — COEP, COOP, and CORP — are newer and frequently overlooked outside of high-security contexts. They're required if you want to use

    SharedArrayBuffer
    or high-resolution timers (both re-enabled under cross-origin isolation as a Spectre mitigation), and they provide meaningful process isolation benefits even if you don't use those APIs.

    add_header Cross-Origin-Embedder-Policy "require-corp" always;
    add_header Cross-Origin-Opener-Policy "same-origin" always;
    add_header Cross-Origin-Resource-Policy "same-origin" always;

    Be careful with COEP. Setting it to

    require-corp
    means every resource your page loads — images, scripts, fonts, iframes — must either be same-origin or be served with an explicit
    Cross-Origin-Resource-Policy
    header that permits embedding. If you load assets from a CDN that doesn't set that header, those resources will be blocked and your page breaks. Test this thoroughly in staging before it goes anywhere near production.


    Full Configuration Example

    Here's the complete

    /etc/nginx/snippets/security-headers.conf
    file, and then the server block that pulls it in:

    # /etc/nginx/snippets/security-headers.conf
    # Security response headers for solvethenetwork.com
    # Managed by infrarunbook-admin on sw-infrarunbook-01
    
    # HSTS - 2-year max-age with subdomains and preload
    add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
    
    # Clickjacking protection
    add_header X-Frame-Options "SAMEORIGIN" always;
    
    # MIME-type sniffing prevention
    add_header X-Content-Type-Options "nosniff" always;
    
    # Content Security Policy
    add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self' wss://solvethenetwork.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self';" always;
    
    # Referrer policy
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;
    
    # Permissions policy
    add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=(), interest-cohort=()" always;
    
    # Cross-origin isolation
    add_header Cross-Origin-Embedder-Policy "require-corp" always;
    add_header Cross-Origin-Opener-Policy "same-origin" always;
    add_header Cross-Origin-Resource-Policy "same-origin" always;

    And here's the server block on sw-infrarunbook-01 that references the snippet:

    server {
        listen 443 ssl http2;
        listen [::]:443 ssl http2;
        server_name solvethenetwork.com www.solvethenetwork.com;
    
        ssl_certificate /etc/letsencrypt/live/solvethenetwork.com/fullchain.pem;
        ssl_certificate_key /etc/letsencrypt/live/solvethenetwork.com/privkey.pem;
    
        ssl_protocols TLSv1.2 TLSv1.3;
        ssl_ciphers HIGH:!aNULL:!MD5;
        ssl_prefer_server_ciphers on;
        ssl_session_cache shared:SSL:10m;
        ssl_session_timeout 1d;
    
        root /var/www/solvethenetwork.com/html;
        index index.html index.htm;
    
        # Suppress Nginx version from Server header
        server_tokens off;
    
        # Strip upstream information leakage if proxying
        proxy_hide_header X-Powered-By;
        proxy_hide_header X-AspNet-Version;
    
        # Pull in all security headers
        include snippets/security-headers.conf;
    
        location / {
            try_files $uri $uri/ =404;
        }
    
        access_log /var/log/nginx/solvethenetwork.com.access.log combined;
        error_log  /var/log/nginx/solvethenetwork.com.error.log warn;
    }
    
    # Redirect all HTTP to HTTPS
    server {
        listen 80;
        listen [::]:80;
        server_name solvethenetwork.com www.solvethenetwork.com;
        return 301 https://$host$request_uri;
    }

    Test the configuration syntax and reload without downtime:

    sudo nginx -t && sudo systemctl reload nginx

    Verification Steps

    Don't trust that your headers are being sent correctly just because

    nginx -t
    passed. Syntax validation and actual runtime behavior are two different things — I've seen configs pass the syntax check and still not send a header because the
    include
    was placed in the wrong block scope. Here's how to verify what's actually going over the wire.

    From the command line on sw-infrarunbook-01 or any machine that can reach the server:

    curl -I -s https://solvethenetwork.com | grep -i -E "(strict-transport|x-frame|x-content|content-security|referrer|permissions|cross-origin)"

    You should see output resembling this:

    strict-transport-security: max-age=63072000; includeSubDomains; preload
    x-frame-options: SAMEORIGIN
    x-content-type-options: nosniff
    content-security-policy: default-src 'self'; script-src 'self'; ...
    referrer-policy: strict-origin-when-cross-origin
    permissions-policy: camera=(), microphone=(), geolocation=(), payment=(), usb=(), interest-cohort=()
    cross-origin-embedder-policy: require-corp
    cross-origin-opener-policy: same-origin
    cross-origin-resource-policy: same-origin

    If any header is absent, double-check that your

    include snippets/security-headers.conf;
    line is inside the correct
    server {}
    block — not in the
    http {}
    block and not accidentally inside a
    location {}
    block where inheritance rules get complicated. Also confirm you reloaded Nginx rather than just running the syntax test.

    For a more thorough audit, point your domain at securityheaders.com or use Mozilla Observatory. Both tools scan your live headers and return a graded report with actionable findings. Running solvethenetwork.com with this configuration should land you at an A or A+ grade. These scanners also catch edge cases like duplicate headers and CSP directive conflicts that curl alone won't surface.

    After your first deployment with CSP in enforcing mode, watch the browser console on a representative page for violation messages. Any blocked resource will appear clearly with the violating directive and the blocked URI, which makes iterative policy refinement straightforward.

    sudo tail -f /var/log/nginx/solvethenetwork.com.error.log

    Keep an eye on the error log too — if COEP is blocking a cross-origin resource, Nginx itself may log something depending on your upstream configuration, though most CSP and COEP enforcement happens silently on the client side.


    Common Mistakes

    The most frequent issue I see in the wild is header duplication caused by adding headers inside both a

    location {}
    block and the parent
    server {}
    block. Nginx doesn't merge these contexts — a nested block's
    add_header
    directives completely override the parent context's directives for that response. So if you add HSTS in your
    server {}
    block and also add a custom header inside a
    location /api/ {}
    block, the HSTS header will disappear from API responses entirely. Keep all your security headers in one place via the snippet include at the
    server {}
    level, and don't add independent
    add_header
    calls in location blocks unless you're prepared to re-include the full snippet there too.

    Second: omitting the

    always
    parameter. By default, Nginx only sends
    add_header
    directives on 2xx and 3xx responses. Your 404 pages, 500 errors, maintenance redirects, and rate-limit responses won't carry the headers. Since browsers process headers on all response codes, this creates real gaps in your enforcement. Add
    always
    to every security header directive, without exception.

    Third: enabling HSTS on a server where HTTPS isn't fully sorted. Once a browser has cached an HSTS entry, it refuses to make HTTP connections for the entire

    max-age
    duration. If your certificate expires, or if a client hits a subdomain still running plain HTTP after you've set
    includeSubDomains
    , the browser shows a hard error with no click-through bypass. Don't treat HSTS as something to set up and forget — it has teeth, and that's the point.

    Fourth: a CSP that uses

    'unsafe-inline'
    or
    'unsafe-eval'
    because it was the quick fix when the policy broke something. I understand the temptation — you're in a time crunch, something broke, and adding
    'unsafe-inline'
    makes it go away immediately. But a CSP with
    'unsafe-inline'
    in
    script-src
    is largely ineffective against XSS, which is the entire threat it's designed to block. If inline scripts are genuinely unavoidable short-term, use a hash- or nonce-based approach. It's more work, but it preserves the actual security value of the policy.

    Fifth: not stripping information disclosure headers from upstream responses. When Nginx is acting as a reverse proxy, backend servers often add headers like

    X-Powered-By: PHP/8.2.7
    ,
    X-AspNet-Version
    , or
    Server: Apache/2.4.54
    . These fingerprint your stack for attackers. Use
    proxy_hide_header
    directives to strip them before they reach clients, and keep
    server_tokens off
    in your Nginx config to suppress the Nginx version from its own
    Server
    header.

    Sixth — and this one catches people off guard — treating this as a one-time deployment. Browser support for security headers evolves constantly. The

    X-XSS-Protection
    header used to appear on every security checklist; it's now considered actively harmful on some browsers because it can introduce new vulnerabilities, and you should set it to
    0
    or omit it entirely. CSP directives get deprecated and replaced. New cross-origin headers appear. Set a recurring reminder to review your security headers configuration at least once a year, track the MDN compatibility tables for headers you're using, and watch the OWASP Secure Headers Project for guidance updates.

    Frequently Asked Questions

    What is the difference between X-Frame-Options and CSP frame-ancestors?

    Both prevent your pages from being embedded in iframes, but they work at different levels. X-Frame-Options is older and understood by virtually every browser, including very old ones. The CSP frame-ancestors directive is more flexible — it supports multiple origin patterns and takes precedence over X-Frame-Options in browsers that support CSP Level 2 and above. You should set both: frame-ancestors in your CSP for modern browsers, and X-Frame-Options as a fallback for older clients.

    Why does Nginx stop sending my HSTS header on error pages?

    By default, Nginx only sends add_header directives on 2xx and 3xx responses. To send headers on all response codes including 4xx and 5xx, you must include the 'always' parameter: add_header Strict-Transport-Security "max-age=63072000" always; Without 'always', a client that receives a 404 or 500 response won't see the header and your HSTS enforcement has gaps.

    My Content-Security-Policy is breaking my site. How do I debug it?

    Start by switching to Content-Security-Policy-Report-Only mode. This sends the header without enforcing it, and browsers will log violations to the developer console without blocking any resources. Open your browser dev tools, navigate to the Console tab, and look for CSP violation messages — each one shows the blocked resource URI and the specific directive that triggered the violation. Use that to iteratively build your policy before switching back to enforcing mode.

    Should I apply security headers to my HTTP server block as well?

    For most headers, no — they only have meaningful effect over HTTPS. HSTS in particular must only be sent over HTTPS; sending it over HTTP is ignored by browsers and could cause confusion. Your HTTP server block should do one thing: redirect all traffic to HTTPS with a 301. The security headers belong in your HTTPS server block where they'll actually be processed by browsers.

    What happens if I put add_header directives in both my server block and a location block?

    Nginx does not merge add_header directives across nested contexts. When you add a header inside a location block, it completely replaces all add_header directives inherited from the parent server block for that response — not just adds to them. This means any security headers you set in your server block (or via an include) will silently disappear from responses matched by that location block. The fix is to include the full security headers snippet inside every location block that needs custom headers, or better yet, avoid standalone add_header calls in location blocks entirely.

    Related Articles