HSTS: the header most HTTPS sites still skip
You turned HTTPS on a long time ago and the padlock has been in the address bar ever since. Open a fresh browser profile, type the bare domain and press enter, and the first packet that leaves the machine is a plaintext GET / HTTP/1.1 on port 80, because nothing has ever told that browser to do otherwise. Your redirect to https:// tidies up after that request. HSTS is the header that stops the request being sent at all, and most sites that could send it do not.
Of the 212,154,494 domains the September 2026 census reached over HTTPS, 44,684,372 (21.1%) answered with a Strict-Transport-Security header and 167,470,122 (78.9%) did not. A visitor sees the same padlock on both, because the only difference is one response header, and one line of server configuration moves a site from the second group to the first.
What a browser does with a domain it has never seen
Type example.com into a fresh browser and it has nothing stored for that hostname, so the defaults apply. An address typed without a scheme defaults to http://, and so do old bookmarks, links in years-old emails, printed URLs and any href="http://..." still sitting in someone else’s page. Each of those produces a request on port 80 that carries the full path and any cookie you have not flagged Secure.
On your office network that request goes out and comes back with a 301 in a few milliseconds and nobody notices. On a public network, or any link where a device between the visitor and your server can answer for port 80, the redirect is whatever that device chooses to send. The visitor cannot tell the difference, because they never saw the response you intended.
A browser that has been told about HSTS handles the same typed address differently. It rewrites the scheme to https:// before opening a socket. Port 80 never hears from it, so there’s no plaintext hop for anything to sit on. All the browser stores is an instruction for that hostname and the date it expires.
Before: the site with HTTPS and nothing else
Two curl calls show the shape of it. The redirect works, but the browser learns nothing it can keep.
$ curl -sI http://www.example.com/
HTTP/1.1 301 Moved Permanently
Server: nginx
Date: Sat, 05 Sep 2026 09:14:02 GMT
Location: https://www.example.com/
Content-Length: 0
$ curl -sI https://www.example.com/
HTTP/2 200
server: nginx
content-type: text/html; charset=utf-8
cache-control: no-cache
Read the second response again and look for strict-transport-security. It isn’t there, so the next time this visitor types the address the browser starts on port 80 again, and the redirect only kicks in after that plaintext request has already left the machine.
The scan reports the same absence as a single line against the domain, and it is a common finding on sites that have otherwise done the TLS work properly.
After: the same site, one header added
$ curl -sI https://www.example.com/
HTTP/2 200
server: nginx
content-type: text/html; charset=utf-8
cache-control: no-cache
strict-transport-security: max-age=31536000; includeSubDomains
The browser records two things when it sees that line over a valid HTTPS connection: this hostname and everything beneath it is HTTPS only, and the instruction lasts one year from now, the max-age value counted in seconds. From then on, open the network panel in developer tools and load http://www.example.com/ and you will see a 307 Internal Redirect carrying the header Non-Authoritative-Reason: HSTS. The 307 comes from the browser itself, before anything leaves the machine.
The header also changes what a certificate error looks like, because with HSTS in force the browser refuses to let a visitor click past one on that host. Chrome shows “You cannot visit www.example.com right now because the website uses HSTS.” and offers no proceed link. Firefox explains that the site has a security policy called HTTP Strict Transport Security and removes the button that would add an exception. Browsers do this on purpose, and it’s why the certificate has to be right before the header goes on.
Be precise about what HSTS leaves alone. It closes the plaintext first request for browsers that have already seen the header once. A visitor who has never been to the site, or whose browser has expired the entry, still starts on port 80 unless the domain is on the preload list. It has no effect on a page that loads its own scripts over http://, and none on anything that happens after the TLS session is up. /headers covers the headers that govern those.
Each directive and the value to pick
max-age is the only required part, in seconds. 31536000 is one year and is the floor the preload list accepts; 63072000 is two years and common on sites that have finished the rollout. The value counts down from the last time the browser saw the header, so a returning visitor keeps resetting the clock. Sites that deploy with a small number and mean to raise it later tend to forget, so put the final value in a ticket before the first one goes live.
includeSubDomains extends the rule to every name under the host that sent it. Sent from example.com, it covers www.example.com, mail.example.com, staging.example.com, and any internal hostname that still answers on plain HTTP. The moment a browser holding the policy tries that last one it stops loading, and there’s no exception dialog to click through. Before adding this directive, list every name your zone answers for and confirm each one serves a valid certificate. The browser rule assumes the zone it covers is the one you published, so if the zone can be altered in transit fix that as well; /dns covers signing it.
preload on its own changes nothing in the browser that reads it. It signals that you consent to the hostname being compiled into the browsers’ built-in HSTS list, which is how a first-ever visit skips port 80 too. The submission checks are strict: max-age of at least a year, includeSubDomains present, the header served from the bare apex, and an HTTP request to the apex answered by a redirect to HTTPS on the same host before any redirect to www. Getting off the list takes months of browser release cycles, so add the token last and only when every subdomain, current and future, will be HTTPS.
Roll it out in the order that cannot lock you out
Start with the certificate, because HSTS turns a certificate warning a visitor could click through into one they cannot. Check the apex and every subdomain you intend to cover, and fix anything that fails before the header exists. /tls matches each browser error code to its cause.
Next, fix the redirect chain. http://example.com should answer with a 301 to https://example.com, and only then should the HTTPS apex redirect to https://www.example.com if that is your canonical host. A redirect straight from the HTTP apex to the HTTPS www host means the browser never sees a header from https://example.com, so the apex never gets the policy and preload will reject the submission.
Then deploy with a short life and no subdomain directive: max-age=300. Five minutes is long enough to see the 307 Internal Redirect in developer tools and short enough that a mistake heals itself before anyone opens a ticket. Live with it for a day. Raise it to max-age=86400, add includeSubDomains once the inventory is clean, and live with that for a week. Only then set the year value.
Send the header on every HTTPS response, including redirects and 404 pages. The browser records it from whichever response it sees first, so a 404 page that leaves it out has wasted that visit.
Where the line goes
On nginx, inside the server block that listens on 443:
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
The always matters, because without it nginx drops the header from any response that is not a 2xx or 3xx. Watch for add_header inside a location block too. nginx does not merge them, so a single add_header in a location discards every header inherited from the server block, HSTS included, for requests that match that location.
On Apache, with mod_headers enabled (a2enmod headers), inside the :443 virtual host:
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains"
On Caddy the line is header Strict-Transport-Security "max-age=31536000; includeSubDomains" inside the site block. Caddy issues the certificate and answers port 80 with the redirect on its own, so the header is the only piece to add.
If a CDN or reverse proxy terminates TLS in front of your origin, set the header at the edge and remove it from the origin. Two Strict-Transport-Security headers on one response is what you get when both layers set it, and browsers act on the first one they parse, which may be the short test value you forgot about. Web frameworks mostly offer a one-line setting for the header; check the value they emit with curl instead of trusting the documentation, because defaults have changed between versions.
Taking it back
A site that needs to return to HTTP for a host, or that added includeSubDomains too early, serves Strict-Transport-Security: max-age=0 over HTTPS. Browsers that see it delete their entry for that host. The instruction has to arrive over a valid HTTPS connection, because browsers ignore the header on plain HTTP by design, so a host whose certificate has broken can’t use it to escape its own policy, which is the position the rollout order above keeps you out of.
To clear a single browser during testing, Chrome exposes its store at chrome://net-internals/#hsts with a “Delete domain security policies” field, and Firefox drops the entry when you choose “Forget About This Site” from history. Neither touches anyone else’s browser, which is why the short max-age at the start matters.
Confirm it from outside
Run the check against the apex and against a URL on your canonical host that you know returns 404, since both should carry the line:
curl -sI https://example.com/ | grep -i strict
curl -sI https://www.example.com/no-such-page | grep -i strict
An empty result on either means the header is conditional somewhere in the chain. Once both print the same value, run the scan again and keep the output as the baseline for the next change.
Figures as of 5 September 2026, from the September 2026 edition of the defaults.exposed census. Census numbers move every month; the current values are on the census data page.