Security / security / transport / browser
HSTS Protects Every Visit Except the First
That adding the Strict-Transport-Security header makes a site HTTPS-only. The header is an instruction to a browser that has already completed an error-free HTTPS conversation with you, so it does nothing on a first visit, nothing after a cleared profile, and nothing at all for a client on a network where the attacker strips it — RFC 6797 calls this the bootstrap MITM vulnerability in section 14.6. The other half is worse in the opposite direction: once stored, the policy is enforced by the browser with no click-through on any certificate error, and it can only be revoked by serving max-age=0 over the very HTTPS connection that may be the thing that broke. max-age is not a setting, it is the length of an outage you are agreeing to in advance.
HTTP Strict Transport Security — HSTS — is not a switch on your server that makes the site HTTPS-only. It is a note the browser writes down after it has already had one clean HTTPS conversation with you, and then obeys. Which means it does nothing on the visit where it would have helped most, and everything on the visit where your certificate has just expired.
The header is one line: Strict-Transport-Security: max-age=31536000;
includeSubDomains. RFC 6797 turns it into an entry in a store the
browser keeps — the host's name, an expiry time, and whether subdomains are
covered. From then on, whenever that browser is about to load any
http:// URL, it checks the store first and rewrites the scheme
before a packet leaves the machine. Nothing about this happens on your
server. All of it happens in a store you cannot read, on a machine you do
not control, for a duration you chose in advance.
The panel is that store, plus one navigation. Pick what is happening, set the header you serve, and read the log: every rule the browser applied, with its section number.
Start with a first-ever visit, with an attacker on the network, and turn max-age up as high as it will go.
the policy did its job · a judgement call, not a rule · where the harm happened · context
This store belongs to one browser profile on one machine. Every other client in the world has its own, in a different state, and you cannot see any of them.
On a first-ever visit the max-age slider does nothing at all. Two years, one
year, five minutes: one plaintext request leaves the browser either way, and
the attacker answers it and drops your header on the floor. RFC 6797 names
this in §14.6 — the bootstrap MITM vulnerability — and is blunt about
it: the user agent uses an insecure channel in the initial attempt to
interact with the specified server
. The header cannot protect the
request that would have carried it to you.
The first visit, and the two ways to survive it
Switch to a returning user and bring max-age back down to
31536000 — one year — from the two years you left it on. Now the
slider matters: at one day since the last
visit, the store holds a live entry, §8.3 rewrites the scheme, and zero
plaintext requests are sent. Push days to 400 and the entry is gone —
§8.1.1 requires the browser to evict expired entries — and the attacker gets
the same plaintext request they got on day one.
That is the actual shape of the protection: a sliding window that starts over
on every visit. §11.2 spells out the two ways to run it. The
constant value into the future
approach sends the same
max-age every time, and each receipt of this header by a UA
will require the UA to update its notion of when it must delete its knowledge
of this Known HSTS Host
. The fixed point in time
approach computes
a shrinking value per response. Almost everyone does the first, which means
a daily visitor is permanently covered and a once-a-year visitor is
permanently uncovered, whatever number you picked.
There are exactly two ways out of the first-visit hole, and they are not the same kind of thing.
The preload list. Tick it and go back to the first-ever visit: zero
plaintext requests, because the policy was in the browser before the browser
ever heard of you. §12.3 describes this as vendor pre-configuration
in a manner similar to how root CA certificates are embedded in browsers
at the factory
, and it is deliberately outside RFC 6797's protocol —
nothing you serve puts you on it. The submission requirements at
hstspreload.org are four, and only the last is about the header: serve a
valid certificate; redirect from HTTP to HTTPS on the same host if you are
listening on port 80; serve all subdomains over HTTPS; and serve, on
the base domain, an HSTS header carrying max-age of at least
31536000, includeSubDomains, and the
preload token. The third is the one that costs, and the site
closes the obvious escape hatch in a note: preloading applies to all
subdomains, including internal subdomains that are not publicly
accessible.
Note also what the preload token is not: sending
it in your header does nothing by itself. It is a signal of
consent that the list operator reads, not a mechanism.
The browser deciding for itself. Untick preload again — leave
it on and the policy is already in the browser, so there is nothing left for
this to demonstrate — then tick Browser warns before any
plaintext load of a public site, still on the first-visit scenario. The
plaintext count drops to zero without any header from you at all. This is
Chrome's Always Use Secure Connections
, which Google has said will
become the default for public sites in Chrome 154 in October 2026 — as of
today that is a scheduled change, not a shipped one, and it has been the
default in Incognito since Chrome 127. Read what it does carefully, because
it is the thing people get wrong: it warns before the first access to
a public site without HTTPS. It does not block. Tick The user clicks
through security warnings and the plaintext count goes back to 1. A
warning is not a policy; it is a question, and users answer questions
quickly.
includeSubDomains is not politeness
Switch to the third scenario, put days back to 1 so the policy is live again, turn includeSubDomains off, and leave the user clicks through on. The outcome is domain cookie taken, with zero plaintext requests sent. Nothing was downgraded. The connection was HTTPS throughout.
§14.4 of the RFC is titled The Need for includeSubDomains
and gives
this attack in full, down to the invented hostname
uxdhbpahpdsf.example.com that the panel reuses. The reasoning is
a collision between two scoping rules. HSTS entries are scoped by hostname,
with subdomains covered only if you said so. Cookies are scoped by
registrable domain, always, and the Secure flag constrains the
scheme, not the host — see
the way cookie scope works for why that is a
property of the cookie model rather than an oversight. So an attacker who can
put any name at all under your domain in the DNS gets a host that your
browser will happily send your domain cookie to, and that your HSTS policy
has never heard of.
Because that host is not a Known HSTS Host, §8.4 does not bind, and the
browser shows an ordinary certificate interstitial with a proceed button.
The RFC's own words: Without the includeSubDomains directive, HSTS is
unable to protect such Secure-flagged domain cookies.
Now turn includeSubDomains back on and run it again. The verdict flips
to attack blocked and the log points at §8.2's superdomain match:
uxdhbpahpdsf.example.test ends in .example.test,
the entry for example.test asserts includeSubDomains,
so the subdomain is a Known HSTS Host by inheritance. §8.4 now binds, the
certificate error terminates the connection, and §12.1 removes the button.
The user's willingness to click through has become irrelevant, which is the
entire design intent: allowing user recourse such as clicking through
warning/error dialogs is a recipe for a man-in-the-middle attack.
The cost of that directive is exact and worth stating: every name under your
domain, for the duration of the max-age, including ones that do not exist yet
and ones run by teams you have never met. §11.4.1 works through a case the
authors clearly had in front of them — a certificate authority at
ca.example.com whose certificates point at
http://crl-and-ocsp.ca.example.com/ for revocation data. Assert
includeSubDomains on the parent and every HSTS-aware browser
that has visited the web application would fail to retrieve CRLs and fail
to check OCSP for certificates, because these services are offered over plain
HTTP.
The RFC's list of fixes is short and none of them are small: serve
those subdomains over TLS too, move them to a different registrable domain,
or stop needing plain HTTP there at all. The Support HSTS without
includeSubDomains
issue filed against self-hosted software is what this
looks like when the intranet finds out second.
The one-way door
Set the scenario to your certificate expired this morning, with a live
policy and no attacker anywhere. The outcome is unreachable, no way in
and the log stops at §8.4: the browser MUST terminate the connection on any
error, whether warning or fatal or any other error level
, and §12.1
says to do it with no user recourse.
This is the same rule that blocked the attack in the previous section,
executing on a morning when there is no attacker. You did not misconfigure
anything. Nothing has failed except the certificate. The RFC is explicit that
this is the deal: If a web application issues an HSTS Policy, then it is
implicitly opting into the no user recourse approach.
HSTS converts every
certificate problem from a degraded experience into a total outage, and it
does so for the exact duration you chose in your header.
Now try to get out of it. Switch to the last scenario, which serves
max-age=0. §6.1.1 is unambiguous — zero signals the UA to
cease regarding the host as a Known HSTS Host, including the
includeSubDomains directive
— and the log shows the entry disappearing.
Then read the last line. That removal reached one browser: the one that made
a request. With the slider at 63072000, the readout says the
withdrawal takes up to 730 days, because a client that does not come
back keeps enforcing the policy it already has. There is no push. There is no
revocation endpoint. It is the same structural problem as
recalling a token you have already issued, with the
same only-real-lever: the expiry you set before you needed it.
And the withdrawal requires a successful HTTPS response — which is precisely
what you do not have on the morning your certificate expired. The two
scenarios interlock. You cannot serve max-age=0 over HTTP,
because §8.1 requires the browser to ignore an STS header that arrives
insecurely, and §7.2 requires the server not to send one there in the first
place.
Tick preload and run the rollback again. The stored entry still goes,
and the readout still says still enforced everywhere, because the
preload entry is not state — it is a table compiled into the browser binary.
hstspreload.org states the consequence in its own words:
Be aware that inclusion in the preload list cannot easily be undone
,
and Domains can be removed, but it takes months for a change to reach
users with a Chrome update and we cannot make guarantees about other
browsers.
The people who preloaded the entire .dev
top-level domain and broke every developer's local hostname overnight were
doing exactly what the list is for; that is what it feels like from the other
side.
Checking it in a real system
1. Look at the store, not the header. Your header is what you send;
the store is what is enforced, and they can differ by up to a year. Chrome
has kept a domain security policy page at
chrome://net-internals/#hsts across many releases: it lets you
query a single host and see the expiry and the
includeSubDomains flag that browser is actually holding, and
delete that one entry. That delete is the only fast fix for a machine that is
stuck, and it fixes exactly one machine.
2. Check the header on a redirect, not just on the final page. A very
common deployment serves Strict-Transport-Security from the
application and terminates TLS at a load balancer that answers
http:// with a 301 of its own. The redirect response is the one
that matters least — §7.2 requires that an HSTS host MUST NOT include the
header over non-secure transport anyway — but the first
HTTPS response, including any HTTPS-to-HTTPS redirect, is where the
policy has to be. curl -sI https://your.site/ and then
curl -sI https://your.site/some/path/that/redirects, and compare.
3. Decide the max-age by asking how long an outage you will accept.
That is the real meaning of the number. If your certificate renewal is
automated, monitored, and has a tested rollback, a year is a reasonable bet.
If it is a calendar reminder owned by someone who left, start at
300, confirm nothing broke, then 86400, then
7776000, then a year. The RFC recommends this shape of caution
from the server side too: §11.2 advises implementers to default
max-age to zero in deployment tooling, so that enabling HSTS is
always a deliberate act.
4. Enumerate your subdomains before asserting includeSubDomains, not
after. Pull them from your DNS zone, not from memory. Every name that
answers on port 80 and is not ready to be HTTPS-only is an outage you are
scheduling. Then check whether anything you do not control can create names
under your domain — a wildcard record, a delegated zone, a hosting product
that hands customers *.yourdomain — because §14.4's attack needs
exactly one of those.
5. Treat preload as a separate, larger decision. It is the only way to close the §14.6 hole for a first-time visitor, and it is the only part of this you cannot undo on your own schedule. The four requirements are cheap; the commitment is that every hostname under your domain must serve HTTPS for as long as it takes a removal to propagate through browser releases. Submit when HTTPS everywhere has been true and boring for a year, not when you are trying to make it true.
6. Never point a preloaded or includeSubDomains policy at a name you use
locally. This is the failure behind help I accidentally enabled HSTS
on localhost
: a browser that has noted localhost, or a
development domain covered by a superdomain rule, will refuse your
self-signed certificate with no way through, and the fix is a per-machine
delete in net-internals on every machine. Use a domain you own and can serve
real certificates for, and keep local development on a name no policy covers.
You have served max-age=63072000; includeSubDomains for a
year. You are not on the preload list. Today you decide HSTS was a mistake
and deploy max-age=0. When is the policy gone?
What HSTS is actually for
Read as a threat model rather than a header, HSTS is narrow and precise.
§2.3.1 lists what it addresses: passive network attackers reading traffic on
a shared link, active attackers downgrading a connection, and — the one
people forget — Web Site Development and Deployment Bugs
. That third
one is your own site's stray http:// subresource, and the RFC's
argument for why it is fatal is worth reading: a single insecurely loaded
stylesheet anywhere on the site lets an attacker script the login page,
because a single insecure embedding anywhere on the overall site
compromises the security of their login page.
§2.3.2 lists what it does
not address: phishing, and malware or browser vulnerabilities.
Two further limits are named in the RFC and are the ones that decide whether
HSTS is the right control for your problem. §14.8 describes an attacker who
persuades a user to install a root certificate and then poisons DNS: the
connection is HTTPS, the certificate validates against a trust store the user
themselves damaged, and HSTS is satisfied. This type of attack leverages
vectors that are outside of the scope of HSTS.
Anything that gives an
attacker a certificate your browser will accept walks straight past this
header, which is the gap that
the certificate path checks and
pinning a key rather than trusting a name
address instead.
§14.3 names the other one, and it is the reason for a recurring support
ticket. A browser behind a TLS-inspecting corporate proxy sees certificate
warnings on every host, so an unknown site is never noted — the user stays
unprotected. But a host noted earlier, on a clean network, keeps its policy:
Once the UA successfully connects to an unknown HSTS Host over error-free
secure transport, the host will be noted as a Known HSTS Host. This will
result in the failure of subsequent connection attempts from behind
interfering proxies.
A laptop that visited your site from home and then
joined a network that inspects TLS cannot reach you at all, and there is no
button. That is working as designed, and it is not a sentence anyone wants to
say to a customer.
The mechanism is one sentence long, and every consequence in this lesson falls out of it: the browser stores a name and an expiry, and rewrites schemes until that expiry passes. It is cheap, it works, and the two things it costs you are the first visit and your ability to change your mind.