HTTP Strict Transport Security (HSTS) Deep Dive

This guide is part of the Web Security Headers Fundamentals reference and treats Strict-Transport-Security as a transport-enforcement control with hard operational consequences. HSTS is an application-layer header that dictates transport-layer behavior: it instructs a user agent to refuse plaintext HTTP for a fixed duration, upgrade every http:// reference to https:// before a request leaves the machine, and treat certificate errors as non-bypassable failures. The two tuning decisions that determine its blast radius — the cache lifetime and the subdomain scope — are covered in detail in how to configure max-age and includeSubDomains for HSTS, and the irreversible decision of whether to enter the browser preload list is weighed in HSTS preload vs manual enforcement trade-offs.

Threat Model & Protocol Mechanics

HSTS exists to close one specific window: the gap between a user typing a bare hostname and the server’s redirect to HTTPS. Without HSTS, the first request to example.com leaves the browser as http://example.com. An on-path attacker — a hostile Wi-Fi access point, a compromised router, a malicious upstream ISP — intercepts that plaintext request and never lets it reach the real origin. This is the SSL stripping attack popularized by sslstrip: the attacker terminates HTTPS to the origin, serves HTTP to the victim, and silently rewrites every link so the victim never sees a TLS indicator. Session cookies, credentials, and CSRF tokens all traverse the wire in cleartext. The 301 redirect to HTTPS that the origin would have sent is exactly what the attacker suppresses.

Strict-Transport-Security is defined by RFC 6797. When a browser receives the header over a valid HTTPS connection, it records a known HSTS host entry containing the policy expiry (derived from max-age), whether the policy includes subdomains, and the timestamp of receipt. From that point until expiry, the browser performs the upgrade itself: any attempt to load an http:// URL for that host — typed, linked, redirected, or embedded — is internally rewritten to https:// before any network activity occurs. There is no plaintext request for an attacker to intercept. Just as importantly, certificate errors become fatal: where a normal HTTP error page offers a “proceed anyway” link, an HSTS host rejects the connection outright with no click-through, defeating attackers who present a self-signed or mismatched certificate.

Trust On First Use and the bootstrap gap

This protection is built on Trust On First Use (TOFU). The browser only learns the policy after one successful, untampered HTTPS response. The very first contact with a domain — on a brand-new device, a cleared browser profile, or after the policy expires — is unprotected, because the browser has no cached entry yet. An attacker who controls the network during that first contact can strip the connection before the header is ever delivered. RFC 6797 acknowledges this bootstrap gap explicitly. It is the entire reason the preload list exists: by shipping the policy inside the browser binary, preloading removes the first-visit window so that even the bootstrap request is forced to HTTPS. The economics and irreversibility of that choice are dissected in HSTS preload vs manual enforcement trade-offs.

The distinction below — header-driven enforcement that only begins after the first response, versus preload enforcement baked into the binary — is the mental model engineers most often get wrong.

HSTS first-visit TOFU versus cached enforcement A sequence comparing an unprotected first HTTP request that can be stripped, the cached HTTPS upgrade on later visits, and the preloaded case where the very first request is already forced to HTTPS. Browser Origin

First visit (no cache) http:// request - strippable 301 + STS header (cache set)

Later visit (cached) internal upgrade https:// only - no plaintext

Preloaded (in binary) forced upgrade https:// on first request

First contact is strippable until the policy is cached; once cached (or preloaded) the browser upgrades to HTTPS before any packet leaves.

HSTS does not act alone. It guarantees the transport channel, but it says nothing about what content is permitted to load over that channel — that is the job of Content Security Policy, whose upgrade-insecure-requests and block-all-mixed-content directives complement HSTS by forcing in-page subresources onto HTTPS. HSTS also does nothing to stop a page being framed; pair it with framing controls described in Cross-Origin Frame Controls & X-Frame-Options. Treat HSTS as the floor of the stack: the layer everything else assumes is already encrypted.

Directive Syntax & Spec

The header carries one mandatory directive and two optional valued/valueless tokens. Parsing per RFC 6797 is case-insensitive on directive names and order-independent, but a malformed value causes the browser to silently discard the entire header — there is no console warning in most engines.

Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
Directive Type Default Security Impact When to Deviate
max-age=<seconds> Required integer none (header ignored if absent) Sets how long the browser enforces HTTPS without a fresh response. Larger values shrink the re-exposure window after cache expiry. Use a short value (300) only during staged rollout; production targets 31536000+
includeSubDomains Optional flag off Applies the policy to every subdomain, including ones the browser has never visited. Closes downgrade gaps on api., cdn., mail. hosts. Omit only while a subdomain still lacks valid TLS
preload Optional flag off A consent signal for inclusion in the browser preload list; has no effect on its own. Eligibility requires max-age31536000 plus includeSubDomains. Set only when you intend to submit and accept near-permanent enforcement

Common malformed-syntax gotchas that produce a silently ignored header:

Platform-Specific Implementation

Emit HSTS at the edge or reverse proxy so the header is present on every response — including error pages and responses served while the application is down. Each block below uses the platform’s exact directive and the flag that forces emission on non-200 responses.

Nginx

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

The always flag is mandatory: without it Nginx omits add_header directives on 4xx/5xx responses, leaving error pages unprotected. Note that an add_header in a location block replaces all inherited headers — re-declare HSTS in any location that adds its own headers. Verify: curl -sI https://example.com | grep -i strict-transport-security

Apache

<VirtualHost *:443>
    ServerName example.com
    Header always set Strict-Transport-Security "max-age=63072000; includeSubDomains; preload"
</VirtualHost>

always (the condition for the set action) ensures the header is appended even on internally generated error documents; the default onsuccess table skips them. Requires mod_headers. Verify: apachectl -M | grep headers then curl -sI https://example.com | grep -i strict

IIS

<configuration>
  <system.webServer>
    <httpProtocol>
      <customHeaders>
        <add name="Strict-Transport-Security" value="max-age=63072000; includeSubDomains; preload" />
      </customHeaders>
    </httpProtocol>
  </system.webServer>
</configuration>

IIS injects customHeaders on every response from the site, but it has no HTTPS-only condition — only bind this site to 443, or add a URL Rewrite outbound rule gated on {HTTPS} so the header is never emitted over plaintext. Watch for a fronting load balancer adding a second copy. Verify in IIS Manager → HTTP Response Headers, then confirm with curl.

Node/Express (Helmet)

const helmet = require('helmet');
app.use(
  helmet.hsts({ maxAge: 63072000, includeSubDomains: true, preload: true })
);

Helmet writes the header at the application layer, so it is vulnerable to middleware ordering: register it before any route handler or error handler, otherwise responses that short-circuit early ship without it. Behind a TLS-terminating proxy, also set app.set('trust proxy', 1) so Express treats forwarded requests as secure. Verify: curl -sI https://localhost:3000 | grep -i strict

Cloudflare

In the dashboard, go to SSL/TLS → Edge Certificates → HTTP Strict Transport Security (HSTS), enable it, set Max-Age to 12 months or more, and toggle Include subdomains and Preload to match your wire policy. Cloudflare emits the header at the edge on all proxied responses, so remove any duplicate origin-level add_header to avoid two conflicting copies. Verify: curl -sI https://example.com | grep -i strict

Verification & Diagnostic Workflows

Treat verification as a gate, not an afterthought: a header that parses on your laptop can still be stripped by a CDN cache rule or missing on a sibling subdomain.

Header presence and exact value:

curl -sI https://example.com | grep -i strict-transport-security
# strict-transport-security: max-age=63072000; includeSubDomains; preload

TLS chain health (HSTS hard-fails on any certificate error, so the chain must be clean before enforcement):

openssl s_client -connect example.com:443 -servername example.com < /dev/null 2>/dev/null \
  | grep -E 'Verify return code|subject='
# Verify return code: 0 (ok)

Confirm subjectAltName covers every host you assert includeSubDomains over — a SAN gap is the single most common cause of post-deployment lockouts.

Browser cache inspection: open chrome://net-internals/#hsts, paste the host into Query HSTS/PKP domain, and confirm static_sts_domain (preloaded) or dynamic_sts_domain (header-cached) with the expected expiry. The same screen’s Delete domain security policies field clears a stuck local entry during testing.

Preload list status:

curl -s "https://hstspreload.org/api/v2/status?domain=example.com"
# {"name":"example.com","status":"preloaded", ...}

CI/CD regression gate — fail the pipeline if the header drifts:

hdr=$(curl -sI https://example.com | grep -i '^strict-transport-security:')
echo "$hdr" | grep -qi 'max-age=63072000' \
  && echo "$hdr" | grep -qi 'includesubdomains' \
  || { echo "HSTS policy drift"; exit 1; }

Troubleshooting, Misconfigurations & Safe Rollback

HSTS is deliberately hard to reverse, because a reversible transport guarantee would be no guarantee at all. Plan rollback before deployment, not during an incident.

Strict-Transport-Security: max-age=0; includeSubDomains

Frequently Asked Questions

Does HSTS protect the very first visit to my site? No. Header-based HSTS relies on Trust On First Use: the browser only enforces the policy after one clean HTTPS response. The first request on a fresh profile can still be stripped. Closing that window is the sole purpose of the preload list.

Is HSTS delivered inside the TLS handshake? No. It is an ordinary HTTP response header sent after the handshake completes. That is why it must be served over HTTPS — RFC 6797 requires browsers to ignore it on plaintext connections — and why a clean certificate chain is a prerequisite, not an output, of enforcement.

Why is my correctly configured header being ignored? The most frequent causes are a quoted max-age value (max-age="31536000"), a trailing semicolon after preload, delivery over HTTP rather than HTTPS, or a certificate error on that response. Any of these makes the browser silently discard the header.

Can I set a short max-age to make rollback easy and still preload? No. Preload eligibility requires max-age of at least one year (31536000) together with includeSubDomains. A short max-age is the right tool for staged rollout, but it disqualifies you from preloading until you commit to the long value.

Does removing the header turn HSTS off? No. Deleting the header simply stops refreshing the policy; cached entries keep enforcing until their max-age expires. To actively clear enforcement you must serve max-age=0 over HTTPS and wait for clients to revisit.