DEV Community

Rasika Dangamuwa
Rasika Dangamuwa

Posted on

Why Your HSTS Configuration Breaks Production: 5 Strict-Transport-Security Traps Every Engineer Hits

HTTP Strict Transport Security (HSTS) is one of the most effective defenses against SSL-stripping attacks and accidental plaintext cookie leaks. Implementing it seems straightforward—you inject a single response header:

Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
Enter fullscreen mode Exit fullscreen mode

Yet in production environments, HSTS frequently causes subtle, stubborn outages. When misconfigured, it can break staging environments, take internal subdomains offline, or lock your apex domain into browser source trees for months.

Here are the five critical Strict-Transport-Security traps web engineers and DevOps teams hit in production.


1. Returning HSTS Over Plain HTTP (The RFC 6797 Non-Op)

A common mistake in Nginx or Apache configs is returning HSTS during an HTTP-to-HTTPS redirect:

# ❌ INCORRECT: Plain HTTP redirect
server {
    listen 80;
    server_name api.example.com;
    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
    return 301 https://$host$request_uri;
}
Enter fullscreen mode Exit fullscreen mode

Under RFC 6797 Section 8.1, browsers must ignore any Strict-Transport-Security header received over an insecure HTTP connection. Because unencrypted traffic can be tampered with by an active intermediary, user agents only record the policy after a verified TLS handshake.

The Fix: Inject HSTS only inside your TLS server blocks (listen 443 ssl):

# ✅ CORRECT: Add HSTS only on port 443
server {
    listen 443 ssl http2;
    server_name api.example.com;
    ssl_certificate /etc/ssl/certs/example.crt;
    ssl_certificate_key /etc/ssl/private/example.key;

    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
}
Enter fullscreen mode Exit fullscreen mode

2. The preload Permanent Trap and Subdomain Outages

The preload directive is an opt-in mechanism submitted to hstspreload.org and hardcoded directly into Chrome, Firefox, Safari, and Edge binaries.

Once preloaded, browsers refuse all unencrypted HTTP connections to your apex domain and every single subdomain, even on a user's very first visit.

The trap: if you have internal dashboards (vpn.internal.example.com), legacy services, or SaaS CNAMEs that lack public TLS certificates, preloading immediately breaks access. Once shipped into browser update channels, removing a domain takes months.

Safe Staging Schedule:

  • Week 1: max-age=300 (5 minutes testing)
  • Week 2: max-age=86400 (24 hours)
  • Month 1: max-age=2592000; includeSubDomains (30 days)
  • Month 2+: max-age=63072000; includeSubDomains; preload (2 years)

If you need to validate directives across Nginx, Caddy, Apache, or Cloudflare, you can test configurations with the free Nutilz HSTS Generator before rolling changes out.


3. Header Duplication Behind Reverse Proxies

Modern deployments often terminate TLS at an ALB, Traefik, or Cloudflare edge while running backend containers in Node.js, Go, or Python.

If your backend framework (like Helmet in Express) adds HSTS while your load balancer also injects it, the browser receives duplicate headers:

Strict-Transport-Security: max-age=31536000; includeSubDomains
Strict-Transport-Security: max-age=63072000; preload
Enter fullscreen mode Exit fullscreen mode

Conflicting headers can lead to parsing errors or unpredictable caching across browsers. Always inspect your live wire headers:

curl -sI https://example.com | grep -i "^strict-transport-security"
Enter fullscreen mode Exit fullscreen mode

Ensure only the outermost TLS termination layer adds the header.


4. includeSubDomains Breaking Local Development

If developers use subdomains of your apex domain for local development (e.g., local.example.com in /etc/hosts), visiting production will cause the browser to pin includeSubDomains.

Subsequent local requests to http://local.example.com:3000 will fail with ERR_SSL_PROTOCOL_ERROR because the browser enforces HTTPS on all subdomains.

The Fix:

  • Use dedicated dev domains (like example.test or .localhost).
  • Use tools like mkcert for local trusted TLS.
  • To emergency-clear pins in Chrome, remove the domain at chrome://net-internals/#hsts.

5. The Sliding TTL: How max-age Resets

max-age is not a fixed expiration date—it is a sliding TTL that resets on every valid HTTPS response.

Strict-Transport-Security: max-age=31536000; includeSubDomains
Enter fullscreen mode Exit fullscreen mode

A visit on day 364 resets the timer back to 365 days. If you ever need to decommission a domain or downgrade a protocol, simply removing the header leaves returning visitors enforcing HTTPS. To explicitly clear client caches, serve:

Strict-Transport-Security: max-age=0; includeSubDomains
Enter fullscreen mode Exit fullscreen mode

Key Takeaways

  1. Never send HSTS over plaintext HTTP (port 80).
  2. Audit all public and internal subdomains before enabling includeSubDomains or preload.
  3. Roll out max-age incrementally over several weeks.
  4. Verify wire headers with curl -sI to avoid proxy duplication.

Before pushing header updates to production, verify your syntax and directive compatibility using the Nutilz HSTS Generator.

Top comments (0)