How to Set Up Nginx with HSTS (HTTP Strict Transport Security)

How to Set Up Nginx with HSTS (HTTP Strict Transport Security)

How to Set Up Nginx with HSTS (HTTP Strict Transport Security)

A client once asked me why their site still occasionally loaded over plain HTTP for some users, even though they had a perfectly good HTTPS redirect in place. The answer, it turned out, was the classic gap in HTTP-to-HTTPS redirection: that very first request. A redirect only kicks in after the browser has already made an initial (unencrypted) connection, which is exactly the window an attacker on the same network can exploit through something like an SSL-stripping attack. HSTS closes that gap, and in this article, I’ll explain exactly what it does, why it matters, and how to configure it correctly in Nginx without locking yourself out of your own domain by mistake — which, trust me, is a very real risk with this particular header if you’re not careful.

What HSTS Actually Does

HTTP Strict Transport Security is a response header that tells a browser, “from now on, always connect to this domain over HTTPS, even if the user types http:// or clicks an old HTTP link.” Once a browser has seen this header from a domain, it will refuse to make a plain HTTP connection to that domain for the duration specified, automatically upgrading any such attempt to HTTPS before a single byte is sent over the insecure connection.

This matters because a standard 301 redirect from HTTP to HTTPS still requires that first HTTP request to happen. An attacker performing a man-in-the-middle attack on an open Wi-Fi network, for instance, could intercept that initial plaintext request and prevent the redirect from ever reaching the browser, serving a fake page instead. HSTS eliminates this window because the browser never sends the HTTP request in the first place — it converts http://example.com to https://example.com internally before making any network call.

Requirements

I want to stress that last point. Do not enable HSTS, especially with a long max-age or the includeSubDomains directive, until you’re confident HTTPS works everywhere on your site. If you enable it prematurely and something on your site or a subdomain is broken over HTTPS, users who’ve cached the HSTS policy will be unable to reach that content over HTTP either, and there’s no quick way to reverse it for those users until the max-age expires.

Step 1: Confirm HTTPS Works Correctly First

Before touching HSTS, verify:

curl -I https://example.com
curl -I https://www.example.com

Check every subdomain you use, and don’t forget things like a blog.example.com or shop.example.com that might not have TLS configured yet if you’re planning to use includeSubDomains.

Step 2: Add the HSTS Header to Your Nginx Config

The header is added using add_header within your HTTPS server block:

server {
    listen 443 ssl;
    server_name example.com www.example.com;

    ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

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

    location / {
        proxy_pass http://127.0.0.1:3000;
    }
}

Let’s break down the directive:

Step 3: Ensure HTTP Requests Still Redirect (For First-Time Visitors)

HSTS only works once a browser has seen the header at least once. First-time visitors who type http://example.com still need an HTTP-to-HTTPS redirect for that very first visit:

server {
    listen 80;
    server_name example.com www.example.com;
    return 301 https://$host$request_uri;
}

This is why HSTS complements, rather than replaces, your HTTP-to-HTTPS redirect — you need both.

Step 4: Test and Reload

sudo nginx -t
sudo systemctl reload nginx

Verify the header is present:

curl -I https://example.com

You should see:

Strict-Transport-Security: max-age=63072000

in the response headers.

Adding includeSubDomains

If you want the HSTS policy to apply to every subdomain of your domain, not just the exact host, add includeSubDomains:

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

This is powerful and risky in equal measure. It means blog.example.com, api.example.com, staging.example.com — literally every subdomain — will also be forced to HTTPS by any browser that has seen this header from example.com, regardless of whether those subdomains have HTTPS configured correctly. If you have even one subdomain that doesn’t support HTTPS properly, enabling includeSubDomains will break it for anyone who has visited your main domain.

I recommend only adding this once you’re certain every current and reasonably foreseeable future subdomain either has working HTTPS or simply doesn’t exist yet (and if it doesn’t exist yet, you’re accepting that it’ll need HTTPS from day one when it does).

Adding preload and Submitting to the HSTS Preload List

Browsers maintain a hardcoded list of domains that should always be accessed over HTTPS, even before the very first visit — this eliminates the “first request” gap entirely, since the browser never needs to receive the HSTS header at all for preloaded domains; it already knows.

To be eligible for preloading, your HSTS header needs:

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

Requirements for preload list submission (as of the current policy at hstspreload.org):

Once your configuration meets these requirements, you submit your domain at hstspreload.org. Be aware this is a serious commitment: removal from the preload list, once your domain is baked into browser source code, can take months to propagate out to users as browsers ship updates. Don’t submit a domain you’re not fully confident about.

Testing Your HSTS Configuration Thoroughly

Beyond the basic curl check, I recommend:

  1. Check header presence on every response type, including error pages: curl -I https://example.com/nonexistent-page The header should still be present on the 404 response, confirming your always flag is working.
  2. Use an online header checker or browser developer tools (Network tab, check response headers) to confirm the exact header value as seen by a real browser.
  3. Test subdomains individually if using includeSubDomains: curl -I https://blog.example.com curl -I https://api.example.com
  4. Use the SSL Labs test (ssllabs.com/ssltest) which specifically reports on HSTS configuration as part of its overall TLS grading.

Troubleshooting Common Issues

Problem: The header isn’t showing up at all.

Check that add_header is inside the correct server block (the HTTPS one, listening on 443) and not accidentally only in the HTTP block, where it would be meaningless. Also confirm there isn’t a duplicate add_header Strict-Transport-Security directive elsewhere in your config being overridden — Nginx only uses add_header directives from the most specific matching block; if a location block has its own add_header directives without repeating the HSTS one, the outer one may get dropped depending on directive inheritance rules. As a rule, if any add_header appears in a location block, you generally need to repeat all headers you want in that block, since add_header directives don’t merge across nested contexts the way you might expect.

Problem: A subdomain broke after enabling includeSubDomains.

This is the classic HSTS mistake. If a subdomain doesn’t support HTTPS and a user has already cached the parent domain’s HSTS policy with includeSubDomains, that subdomain becomes completely unreachable over HTTP for that user until the max-age expires or they manually clear HSTS settings in their browser (which most users won’t know how to do). The fix is to get HTTPS working on that subdomain immediately, since removing the header from the parent domain won’t help users who already cached the old, longer-lived policy.

Problem: I need to “undo” HSTS quickly.

You mostly can’t, for users who’ve already cached the policy, until max-age expires. You can set a very short max-age (like max-age=0) going forward, which will cause browsers to drop the cached policy on their next visit, but this only helps users who visit again during the (now much shorter) remaining validity window of their existing cached policy. This is exactly why testing thoroughly before enabling HSTS — and starting with a short max-age — matters so much.

Problem: Mixed content warnings even with HSTS enabled.

HSTS doesn’t fix mixed content (HTTP resources loaded from an HTTPS page); it only forces the top-level navigation to HTTPS. You still need to fix any hardcoded http:// references to images, scripts, or stylesheets within your pages separately.

Security Considerations

Performance Tips

HSTS itself has negligible performance overhead — it’s a small header on each response. The real performance benefit, somewhat indirectly, is that once a browser has cached the HSTS policy, it skips the initial HTTP request and redirect round-trip entirely for subsequent visits, connecting directly over HTTPS. That’s a small but real latency improvement, particularly noticeable on mobile networks with higher round-trip times.

Real-World Use Cases

Best Practices Summary

HSTS Across Multiple Server Blocks and Domains

If you run multiple domains or virtual hosts from the same Nginx instance, remember that HSTS is applied per-domain by the browser, based on the header received from that specific domain — there’s no cross-domain inheritance. Each server block serving a distinct domain needs its own add_header Strict-Transport-Security line if you want HSTS enforced there too:

server {
    listen 443 ssl;
    server_name shop.example.com;
    add_header Strict-Transport-Security "max-age=63072000" always;
    # ...
}

server {
    listen 443 ssl;
    server_name blog.example.net;
    add_header Strict-Transport-Security "max-age=63072000" always;
    # ...
}

If you’re using a shared snippet file to avoid repeating this across many server blocks, that’s a reasonable approach:

# /etc/nginx/snippets/hsts.conf
add_header Strict-Transport-Security "max-age=63072000" always;

Then include it where needed:

server {
    listen 443 ssl;
    server_name shop.example.com;
    include snippets/hsts.conf;
    # ...
}

This keeps your policy consistent across domains without manually duplicating the exact header string everywhere, which reduces the chance of a typo or an inconsistent max-age value creeping in across different parts of your infrastructure.

Frequently Asked Questions

Does HSTS protect against all man-in-the-middle attacks?

No. It specifically protects against SSL-stripping and downgrade attacks that rely on intercepting the initial plaintext HTTP request. It doesn’t protect against a compromised certificate authority, a user who’s been tricked into installing a malicious root certificate, or attacks that don’t involve a protocol downgrade at all. It’s one solid layer among several you need for comprehensive TLS security.

What’s the difference between HSTS and just using a 301 redirect?

A 301 redirect from HTTP to HTTPS still requires the browser to make an initial HTTP request before it can be redirected — and that initial request is exactly the window an attacker can intercept. HSTS, once cached by the browser, prevents that initial HTTP request from ever being sent in the first place for the duration of the policy. They’re complementary, not interchangeable; you need the redirect for genuinely first-time visitors and HSTS for everyone after that.

Can I set different max-age values for different parts of my site?

Not meaningfully — HSTS is a domain-level (or with includeSubDomains, a domain-and-subdomain-level) policy, not a path-level one. If you set the header differently across different location blocks on the same domain, the browser’s actual behavior can become unpredictable since it wasn’t designed with intra-domain path-level distinctions in mind. Keep your HSTS header consistent across your entire domain.

How do I remove my domain from the HSTS preload list if I change my mind?

You submit a removal request through hstspreload.org, but be aware the removal process is significantly slower than addition — it can take multiple browser release cycles (often several months) for the removal to actually propagate to users, since it depends on new browser versions shipping with the updated list. This is exactly why I recommend being very deliberate before submitting in the first place.

Is HSTS relevant if I’m already using a CDN that enforces HTTPS?

Often yes, still worth setting, because HSTS is a browser-level policy independent of your specific infrastructure. Even if your CDN enforces HTTPS at its edge, HSTS ensures the browser itself won’t attempt an insecure connection in the first place, which is a meaningfully different (and earlier) point of protection than anything your CDN or origin server can enforce after a request has already been made.

Wrapping Up

HSTS closes a real and exploitable gap in HTTP-to-HTTPS redirection, and configuring it in Nginx takes all of one line once you understand what each directive actually does. The complexity isn’t in the syntax — it’s in the rollout discipline. Test thoroughly, start conservative, and only ratchet up to the long-lived, subdomain-wide, preloaded configuration once you’re genuinely confident nothing will break. Get that sequencing right, and HSTS becomes a quiet, effective piece of your security posture that you’ll rarely think about again.

Exit mobile version