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 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-age ≥ 31536000 plus includeSubDomains. |
Set only when you intend to submit and accept near-permanent enforcement |
Common malformed-syntax gotchas that produce a silently ignored header:
- Quoting the whole value inside the directive, e.g.
max-age="31536000"— the quotes belong in the config file’s value argument, never in the wire syntax. max-agebelow the preload floor (31536000) while sendingpreload— the submission is rejected and the token is meaningless.- A trailing semicolon or stray whitespace after
preload. - Sending the header over plaintext HTTP — RFC 6797 mandates that a browser ignore the header unless it arrived over an error-free HTTPS connection.
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.
- Header missing on error pages → add the
alwaysflag (Nginx/Apache); a header absent on the 5xx an attacker can induce defeats the control. - Header ignored entirely, no error → malformed value (quoted
max-age, trailing semicolon) or served over HTTP. Inspect the raw wire bytes withcurl -sI. NET::ERR_CERT_*with no proceed button on a subdomain →includeSubDomainsis enforcing on a host whose certificate SAN doesn’t cover it. Fix the certificate; you cannot click through an HSTS error.- Duplicate header → both the CDN/edge and the origin emit HSTS. Browsers honor the first; remove one source.
- Need to roll back enforcement → serve
Strict-Transport-Security: max-age=0(mirrorincludeSubDomainsif you used it) over HTTPS. This tells browsers to expire the cached entry, but it only reaches clients that revisit before their existingmax-agelapses — a longmax-ageleaves many users locked out for the full original duration with no server-side remedy.
Strict-Transport-Security: max-age=0; includeSubDomains
- Preload is effectively irreversible → removal via the
hstspreload.orgremoval form, followed by months of browser release cycles before the entry leaves stable channels.max-age=0does not undo preloading. Never preload a domain whose subdomain TLS coverage you cannot guarantee for years.
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.