This morning I lined up the HSTS headers of my own domains. All of them are mine, I configured every one of them, and the four tell four different stories:
$ curl -sI https://burncpu.com/ | grep -i strict
strict-transport-security: max-age=63072000; includeSubDomains; preload
$ curl -sI https://sysroot.app/ | grep -i strict
strict-transport-security: max-age=31536000; includeSubDomains; preload
$ curl -sI https://panel.sysroot.app/ | grep -i strict
(empty)
$ curl -sI https://mustafaerbay.com.tr/ | grep -i strict
strict-transport-security: max-age=31536000
Then I asked Chrome's preload service about the same four names:
$ curl -s "https://hstspreload.org/api/v2/status?domain=burncpu.com"
{"name":"burncpu.com","status":"unknown","bulk":false,"preloadedDomain":""}
$ curl -s "https://hstspreload.org/api/v2/status?domain=sysroot.app"
{"name":"sysroot.app","status":"preloaded","bulk":false,"preloadedDomain":"app"}
$ curl -s "https://hstspreload.org/api/v2/status?domain=panel.sysroot.app"
{"name":"panel.sysroot.app","status":"preloaded","bulk":false,"preloadedDomain":"app"}
$ curl -s "https://hstspreload.org/api/v2/status?domain=mustafaerbay.com.tr"
{"name":"mustafaerbay.com.tr","status":"unknown","bulk":false,"preloadedDomain":""}
burncpu.com has been sending preload since at least May; the oldest config backup on the server is dated May 16 and the line is there too. It is not on the list. Because I never applied. panel.sysroot.app, on the other hand, doesn't send so much as the first letter of an HSTS header, sits on a different server behind a different nginx, and is on the list: the entire .app extension is. Anyone reading that table with "whatever the header says is what's true" in mind would misread all four.
My thesis: the word preload in an HSTS header makes the browser do nothing. It's a petition; getting on the list is a separate decision, a separate process, and a commitment that covers everything under your domain and takes months to undo. Four tutorials on this blog contain an HSTS line (one is the nginx reverse proxy post), and in all four I handed out that line with includeSubDomains and preload without once writing what the word does. This post is me paying that debt.
The browser's two memories
HSTS has two separate stores, and they fill by separate rules.
The first is dynamic memory. This is what RFC 6797 describes: when the browser sees a Strict-Transport-Security header in a response that arrived over HTTPS, it notes the domain as "HTTPS only" for max-age seconds. Every subsequent http:// request is rewritten to https:// inside the browser, before it ever touches the network. Section 8.1 of the standard requires the header to arrive over a secure transport; a header received over plain HTTP is ignored, because otherwise anyone on the path could write any policy for any domain. The same section says a max-age=0 deletes the stored policy. So both the write and the delete command for dynamic memory are in the server's hands.
The second is the static list. Section 12.3 of RFC 6797 calls this a "pre-loaded list" and says a browser vendor can embed HSTS policies the way it embeds root certificates; but it defines no preload directive and sets no rule for how you get in. MDN says it plainly: the preload directive is not part of the HSTS specification. The word is how you tell Chromium's hstspreload.org service "you may add me." Once you're added, the policy ships inside Chrome's binary; the browser won't try plain HTTP even on its very first visit. With one condition: the IsBuildTimely check in the same file disables the static list entirely in a Chrome whose build is older than 70 days. A corporate browser that never updates has no preload protection; all you have there is dynamic memory.
Here is how the browser decides an http:// request, as I read it in Chromium's net/http/transport_security_state.cc:
There's an interesting detail in the code. AddHSTSHeader erases the dynamic entry when it sees max-age=0; but the decision function, finding no dynamic entry, falls through to the static list. Result: a domain that's on the list cannot free itself from the HTTPS requirement by sending max-age=0. In an up-to-date browser, a static entry is something no header can overwrite.
The gap between a petition and a decision
hstspreload.org's submission requirements are short: a valid certificate, a redirect from HTTP to HTTPS on the same host if you listen on port 80, all subdomains served over HTTPS, a max-age of at least 31536000, the includeSubDomains and preload directives present, and the header retained on redirect responses. burncpu.com meets every one of them; the service's preloadable endpoint confirms it:
$ curl -s "https://hstspreload.org/api/v2/preloadable?domain=burncpu.com"
{"errors": [], "warnings": []}
Ready and eligible; waiting at the door for four months, because nobody knocked. Sending preload in the header is a prerequisite for getting on the list, not the act itself. Someone has to fill in the form; then the Chromium team commits the entry to source, and nothing happens until that change rides a Chrome release out to users.
The list itself turned out bigger than I assumed. I downloaded transport_security_state_static.json from the Chromium repository, stripped the comment lines, and counted it as JSON:
file : 10.5 MB
total entries : 94,778
bulk-1-year : 84,628 (bulk submissions after Chrome 63)
public-suffix : 57 (TLDs and public suffixes: app, dev, bank, google, amazon...)
ending in .tr : 285 (only one gov.tr: sanayi.gov.tr)
carrying includeSubdomains: 94,528
Ninety-four and a half thousand of ninety-five thousand entries cover subdomains too. That's the nature of the list: the note at the top of the Chromium file defines the "custom" policy as "entries without includeSubdomains or with HPKP," and there are only 394 of those. Being on the list means, in practice, "me and everything beneath me, forever."
The promise .app made without asking me
I didn't think about this when I picked sysroot.app. .app is one of the extensions whose owner had it written into the preload list at the registry level. These are the first entries in the Chromium file, tagged policy: "public-suffix": fifty-one TLDs and six public suffixes. Google's .app, .dev and .page are among them, but the list isn't just Google; .bank, .insurance, and Amazon's and Microsoft's own extensions are there too. The consequence: no subdomain you ever create under this extension can be opened over plain HTTP in a browser. It's true the day you register; it doesn't change if you never send a header, or if you send max-age=0.
Most of the time that's a comfort. There isn't a single HSTS line in panel.sysroot.app's nginx and I don't care; Chrome, Firefox and Safari already refuse to go there over HTTP. But you have to live with the other side of the coin knowingly: under this extension you cannot stand up a temporary service listening on plain HTTP "and sort out the certificate later." The browser rewrites the address to https://, if there's no TLS on the other end you get a connection error, and because section 12.1 of RFC 6797 says the user gets no recourse, there's no warning to click through either. curl works, the browser doesn't; you go looking for the fault on the server, and the fault is inside the browser.
The reverse is also true. You cannot remove a .app domain from the list; it isn't your domain that's there, it's the extension. hstspreload.org's removal page says, for individual domains, "may take 6-12 weeks to reach most Chrome users, and may take longer for other browsers." For a policy that came in at the extension level, even that door doesn't exist.
What includeSubDomains takes with it
The expensive word isn't preload, it's includeSubDomains. hstspreload.org says it in bold: the policy applies not just to what's public but to internal subdomains nobody outside can even see. A company's intranet.company.com, a developer's test.company.com, the printer admin panel, a VM set up ten years ago and forgotten; the moment the apex domain enters the list with includeSubDomains, all of them become unreachable without HTTPS. And this happens not on the machine of the person who submitted the domain, but in every Chrome in the world, at once.
That's why mustafaerbay.com.tr doesn't send includeSubDomains. I left a note at the top of the header snippet on the server on June 3: "no HSTS includeSubDomains/preload (subdomain + rollback safety)". In the first draft of this paragraph I wrote "there are six subdomains, all HTTPS, all redirecting 80 to 443." Then I checked, and I'm glad I did:
$ curl -sI http://foo.mustafaerbay.com.tr/ | head -1
HTTP/1.1 200 OK
$ curl -s http://foo.mustafaerbay.com.tr/ | grep title
<title>Welcome to nginx!</title>
There's a * record in DNS; a random subdomain, over plain HTTP, with no redirect, returns nginx's welcome page. There's also an mta-sts subdomain that today doesn't answer from outside at all; that host's story is in the MTA-STS post. My own "all HTTPS" inventory didn't survive a two-minute check. So not sending includeSubDomains isn't luck, it's insurance: one day, while moving a subdomain to another server, while a certificate renewal blows up, or while showing a client something temporary, I may need a one-minute gap; evidently I need it even today. The preloadable endpoint hands this back to me as two errors: "no includeSubDomains," "no preload." Both are missing on purpose.
I think the trade-off here deserves an honest conversation. Without includeSubDomains, HSTS is incomplete; section 14.4 of RFC 6797 justifies it directly with cookies: a domain cookie sent to every subdomain, even one marked Secure, travels to a made-up subdomain the attacker registered in DNS; with no HSTS policy for that subdomain the user can click through the certificate warning, and the cookie lands in the attacker's hands. MDN's airport Wi-Fi example likewise rests on login.example.com being protected by the apex's policy. If what lives under your domain is a handful of hosts you alone control, all HTTPS, all with automated certificates, includeSubDomains is the right choice. If it's a corporate domain with other teams, other servers and historical artifacts living beneath it, you need to have taken an inventory of the subdomains before you type that word. No inventory, no word.
In nginx the header vanishes and nobody hears it
While checking my own site for this post I found something unpleasant:
$ curl -sI https://mustafaerbay.com.tr/ | grep -ci strict
1
$ curl -sI https://mustafaerbay.com.tr/_astro/ClientRouter.astro_astro_type_script_index_0_lang.Bhen41yz.js | grep -ci strict
0
The HTML response has HSTS; the JavaScript file from the same server doesn't. The reason is one sentence in nginx's add_header documentation: these directives are inherited from the previous level if and only if there are no add_header directives on the current level. I'd put an add_header Cache-Control in the /_astro/ block for caching; that one line wipes out every server-level security header for that block. Along with HSTS, X-Content-Type-Options and Referrer-Policy go too. In the same file's location / block I re-include the header snippet, so I clearly learned this once; I forgot it when writing the /_astro/ block.
For HSTS specifically the damage is limited: the policy reaches the browser with the HTML response the user sees anyway. But it bites in two places. First, cases where a user's first contact isn't HTML; if a link goes straight to a PDF or an API endpoint and that block lost the header, that user never gets the policy. Second, the preload application: hstspreload.org wants redirects to carry the header too. My www.mustafaerbay.com.tr → apex 301 lives in a separate server block, and that block has no add_header:
$ curl -sI https://www.mustafaerbay.com.tr/ | grep -i "^HTTP\|strict"
HTTP/2 301
No header. If the apex had includeSubDomains it would cover www; since it doesn't, a user arriving via www gets only the redirect and picks up the policy on the apex's 200. The interesting part: hstspreload.org's checker doesn't catch this. burncpu.com's www redirect is headerless in exactly the same way, and preloadable still returns zero errors; the checker looks at the applying domain's own redirect chain, not at the neighbouring host. The rule asks for it, the tool doesn't; which is precisely a reason not to say "the tool passed, so it must be right."
The always parameter is a separate trap. By default add_header only adds to 200, 201, 204, 206, 301, 302, 303, 304, 307 and 308 responses; without always, your 404 and 500 pages don't send HSTS. My snippet has always and my 404 page carries the header; I checked, because until you check, "it's there" is just an assumption.
Behind Cloudflare, whose header is it?
This site sits behind Cloudflare's proxy. I could have turned the header on from the Cloudflare dashboard: the Edge Certificates page under SSL/TLS has five settings, an HSTS on/off, a max-age from 1 to 12 months, includeSubDomains and preload as toggles, and a No-Sniff header tacked on beside them. I chose to leave it at the origin; when I connect to nginx from the server itself, over loopback, I see the same header, Cloudflare passes it through untouched. So the day I switch the proxy off, the policy doesn't change.
Cloudflare's own documentation puts a serious warning under that setting: if HSTS is on and you take the DNS record out of the proxy, pause Cloudflare, or disable HTTPS, your site becomes inaccessible to visitors for the duration of max-age. The dangerous moves the document lists are familiar: switching the record from proxied to DNS-only, pausing Cloudflare, pointing nameservers elsewhere. Each has the same outcome as pointing a subdomain straight at a server and forgetting the certificate. The error page is the browser's, not Cloudflare's; and it can't be clicked through. Whoever sends HSTS owns the policy; if you have no plan for taking the proxy out of the path, don't give that setting to the proxy.
One more reminder: Cloudflare's "Always Use HTTPS" redirect and HSTS are not the same thing. The first is an answer the server gives on every request; the second is a decision the browser never asks about again. The redirect doesn't stop the first plain-HTTP request from going out onto the network; HSTS does. The gap between the two is exactly the gap preload fills.
What changed in 2025: subresources are no longer upgraded
A currency note. The Chrome 135 release notes announced, under "HSTS tracking prevention," that HSTS upgrades apply only to top-level navigations and are blocked for subresource requests. The motive is tracking: a third-party site could use the HSTS cache to store an identifier, and the Chromium team closed that channel. In source it's the kHstsTopLevelNavigationsOnly flag; the first lines of the decision function return "no upgrade" for non-top-level requests when that flag is on. The flag's default in features.cc is still off today; Chrome calls it "shipped" in the release notes, meaning the switch is flipped not at build time but through Chrome's server-managed trial mechanism. Don't assume the behaviour in a Chromium derivative; measure it.
In practice this means you can no longer rely on HSTS for an image or script written as http:// inside an HTTPS page; that job belongs to mixed-content blocking, and the feature's explainer ties the rationale to exactly that: since Chrome 81, plain-HTTP resources on HTTPS pages are already either upgraded or blocked. HSTS's job is the address bar and link clicks. You have to separately make sure every address inside the page is https://; "HSTS will fix it anyway" is no longer true.
Step by step: how I'd proceed
The order hstspreload.org recommends is sensible and it's what I'd do: first max-age=300, five minutes. Watch for a week: which subdomains broke, whose certificate isn't renewing, which old application generates http:// links. Then max-age=604800, a week; then 2592000, a month. includeSubDomains should already be in place at every step, because subdomains are what will break, and breaking them with a five-minute policy is far cheaper than breaking them with a one-year one. At the end, 31536000 and the word preload; then filling in the form; then waiting.
Every step on this path is reversible, with a wait as long as the previous max-age. The last step is reversible only in the slowest sense: 6-12 weeks and a release calendar you don't control. So make the decision from an inventory, not from "let's raise the security score": if you know every host under your domain, how every TLS certificate gets renewed, and what you might put under that domain in the next two years, you're ready.
Checklist before going live
- Is
Strict-Transport-Securityin the output ofcurl -sI https://domain/, and also incurl -sI https://domain/no-such-page/? (Withoutalways, the second comes back empty; the trailing slash is there to get a real 404 instead of a 301.) - Does every
locationblock that has its ownadd_headerline re-emit the security headers?curla static file and an API endpoint. - Do the redirects on HTTPS (
www→ apex, old paths) carry the header? Putting it on the plain-HTTP 301 is pointless; the browser doesn't read it there. - What does
hstspreload.org/api/v2/status?domain=say? If it's "preloaded" andpreloadedDomainis your extension itself, the policy is not in your hands; plan accordingly. - Subdomain inventory before typing
includeSubDomains: every record in DNS (wildcard*included), how each one renews its certificate, anything listening on plain HTTP?curla random subdomain; I did, and saw nginx's welcome page. - Who sends the header, origin or proxy? Does your proxy-bypass scenario survive without losing that header?
- The
max-ageladder: 300 → 604800 → 2592000 → 31536000. At least a week on each rung. - Add the word
preloadonly if you will actually fill in the form; and if you will, do it knowing this is a decision for everything beneath you, in every browser, that can't be undone for months.
Two meanings of one word
I started this post with four headers. One has been begging to get on the list for four months and nobody's listening. One is on the list without ever opening its mouth, one sends nothing yet is the most tightly protected of all, and the last is deliberately incomplete; today I learned it's a good thing it is. All four are "correct," because what's correct isn't what the header says but what sits in the browser's memory. In HSTS, what the server says and what the browser knows are two separate ledgers, and the word preload isn't the bridge between them, it's the bridge's application form. Like everyone who copies that word into a config without knowing where the form goes, who signs it and how it's undone, I did the same. From today on, at least I know what I'm writing.
Official Sources
- RFC 6797 — HTTP Strict Transport Security (HSTS)
- MDN — Strict-Transport-Security header and the preload section
- Chromium — transport_security_state_static.json (preload list source)
- Chromium — transport_security_state.cc (dynamic/static decision logic)
- nginx — ngx_http_headers_module: add_header and always
- Cloudflare — HTTP Strict Transport Security (HSTS) settings and warnings
Top comments (0)