DeepConcepts

Security / security / transport / browser

HSTS Protects Every Visit Except the First

The misconception

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.

15 min

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.

What is happening
The header you serve

In the rollback scenario the slider is the max-age you have been serving; the response itself carries max-age=0.

The browser and the user
outcome
plaintext requests sent
policy expires in
time to withdraw it
What the browser did

the policy did its job · a judgement call, not a rule · where the harm happened · context

The browser's HSTS store, afterwards

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.

Why this concept is on the site

Topics are chosen from places engineers visibly get stuck, and the sources are kept with the lesson so the claim is checkable.