HSTS looks deceptively simple. One header, one line, done.
That’s exactly why teams get it wrong.
I’ve seen production rollouts where HSTS was added in a hurry, tested once in Chrome, and then forgotten until something broke: login subdomains stopped working, staging got pinned to HTTPS, or preload got enabled before the company actually controlled every edge of its domain. The header itself is easy. The operational blast radius is where people mess up.
Here’s a real-world style case study based on patterns I’ve seen repeatedly.
The setup
A SaaS company I’ll call Acme Metrics had a pretty normal setup:
www.acmemetrics.examplefor marketingapp.acmemetrics.examplefor the productapi.acmemetrics.examplefor backend APIsstatus.acmemetrics.examplehosted separatelyhelp.acmemetrics.exampleserved by a third-party platform- a legacy redirect from
http://acmemetrics.exampleto HTTPS
They wanted to “improve SSL security” and added HSTS during a broader hardening sprint.
Their first pass looked fine at a glance. It was not fine.
Mistake #1: Setting HSTS only on one hostname
Their Nginx config on www had this:
server {
listen 443 ssl http2;
server_name www.acmemetrics.example;
add_header Strict-Transport-Security "max-age=31536000";
location / {
proxy_pass http://marketing;
}
}
The team verified the response in the browser devtools, saw the header, and checked the task off.
The problem: HSTS is stored per host, unless you use includeSubDomains. Sending it only from www does nothing for app, api, or the apex domain unless users visit those hosts and receive their own HSTS policy.
Before
https://www.acmemetrics.examplesent HSTShttps://app.acmemetrics.exampledid nothttps://acmemetrics.exampleredirected tohttps://www...but did not itself send HSTS on the final response from the apex- users could still be downgraded on unprotected hosts
After
They fixed it by sending HSTS consistently from every HTTPS vhost:
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
And more importantly, they added it to each TLS-enabled server block that actually served responses.
For Apache, the equivalent looked like this:
<IfModule mod_headers.c>
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains"
</IfModule>
The lesson is simple: if you have multiple entry points, don’t assume one host protects the rest.
Mistake #2: Forgetting the always behavior
This one bites people because testing often covers only happy-path 200 OK responses.
Acme had Nginx configured like this:
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains";
Without always, some error responses and internally generated responses may not include the header, depending on config and version behavior. That means users might hit a redirect or error path and never store the HSTS policy.
Before
A normal page returned:
HTTP/2 200
Strict-Transport-Security: max-age=31536000; includeSubDomains
But a 500 or certain redirect paths returned:
HTTP/2 500
No HSTS header.
After
They changed it to:
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
Now the header was present across the responses that mattered:
HTTP/2 301
Strict-Transport-Security: max-age=31536000; includeSubDomains
Location: https://www.acmemetrics.example/
That’s how I prefer to configure it every time. If I’m setting a security policy, I want it sent consistently, not only when the app is behaving nicely.
Mistake #3: Enabling includeSubDomains before inventorying subdomains
This is the big one.
The team upgraded their header to this:
Strict-Transport-Security: max-age=31536000; includeSubDomains
Looks good. Strong even.
Except help.acmemetrics.example was hosted on a third-party platform that still allowed HTTP and had inconsistent TLS behavior during custom-domain provisioning. status.acmemetrics.example had also been left on a separate stack with an expired cert once before.
With includeSubDomains, every subdomain becomes HTTPS-only for browsers that have seen the policy from the parent domain.
That’s not a theory problem. That’s a support-ticket problem.
Before
They rolled out:
Strict-Transport-Security: max-age=31536000; includeSubDomains
Users who had visited the main site started getting hard failures on neglected subdomains. No click-through, no bypass if the cert was bad. HSTS is intentionally unforgiving.
After
They backed off temporarily:
Strict-Transport-Security: max-age=86400
Then they did the boring work they should have done first:
- enumerate all live subdomains
- verify valid certificates on each
- confirm HTTP always redirects cleanly to HTTPS where needed
- retire dead DNS entries
- validate third-party hosted properties
Once they had confidence, they moved in stages:
Strict-Transport-Security: max-age=604800
then:
Strict-Transport-Security: max-age=2592000; includeSubDomains
and finally:
Strict-Transport-Security: max-age=31536000; includeSubDomains
That staged rollout is far less exciting than flipping the “secure” switch in one go, but it saves you from self-inflicted outages.
Mistake #4: Preloading too early
Someone on the team had heard that preload was “best practice,” so they pushed this:
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
This is where people confuse sending the preload token with being ready for preload.
Adding preload to the header does not magically secure your domain. It signals intent. If you submit and get accepted into preload lists before your domain is operationally ready, unwinding that can be painful.
Acme was not ready:
- not every subdomain was under central control
- some internal tools still assumed HTTP on obscure subdomains
- a forgotten regional microsite had broken HTTPS
Before
They treated preload like a header tweak.
After
They treated preload like a one-way infrastructure commitment.
Their checklist became:
- every current and future subdomain must support HTTPS
- certificates must be maintained everywhere
- HTTP must redirect consistently
- domain owners and platform teams must agree on the policy
Only after they had that discipline did they keep:
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
If you’re not sure whether your estate is clean, you’re not ready for preload. Simple as that.
For the official preload requirements and behavior, check browser and web platform documentation such as MDN’s HSTS docs: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Strict-Transport-Security
Mistake #5: Sending HSTS over HTTP
I still see this in audits.
Someone configures both port 80 and 443 with the same header block and assumes they’re covered:
server {
listen 80;
server_name acmemetrics.example;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
return 301 https://$host$request_uri;
}
Browsers ignore HSTS received over plain HTTP. They have to. Otherwise a man-in-the-middle could inject the policy.
Before
The team thought this helped protect first-time visitors.
It did not.
After
They kept the HTTP-to-HTTPS redirect, but understood that the actual HSTS policy must be delivered over HTTPS:
server {
listen 80;
server_name acmemetrics.example www.acmemetrics.example;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name acmemetrics.example www.acmemetrics.example;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
}
HSTS protects subsequent requests unless preload is involved. Don’t expect it to solve the first-visit problem by itself.
Mistake #6: Going straight to a one-year max-age
This isn’t always wrong, but it’s often reckless.
Acme’s initial header was:
Strict-Transport-Security: max-age=31536000; includeSubDomains
For a mature environment, fine. For a messy environment with third-party subdomains, legacy DNS, and inconsistent cert ownership, that’s gambling.
Better rollout
Start small:
Strict-Transport-Security: max-age=300
Then:
Strict-Transport-Security: max-age=86400
Then:
Strict-Transport-Security: max-age=604800
Then scale up once you’ve observed production behavior.
I like gradual max-age increases because they give you a shorter recovery window if you discover something ugly. HSTS is easy to increase and annoying to back out of.
What the final fixed setup looked like
After the cleanup, their production policy was:
Strict-Transport-Security: max-age=31536000; includeSubDomains
And their Nginx baseline looked like this:
server {
listen 80;
server_name acmemetrics.example www.acmemetrics.example app.acmemetrics.example api.acmemetrics.example;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name acmemetrics.example www.acmemetrics.example;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
location / {
proxy_pass http://frontend;
}
}
server {
listen 443 ssl http2;
server_name app.acmemetrics.example api.acmemetrics.example;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
location / {
proxy_pass http://backend;
}
}
They also added a deployment checklist item: any new subdomain must prove HTTPS readiness before DNS goes live.
That process change mattered more than the header.
Quick gut-check list
If you want to avoid the most common HSTS mistakes, ask these before rollout:
- Am I sending HSTS on every HTTPS host that matters?
- Am I using
alwayswhere my server requires it for consistent coverage? - Have I actually inventoried all subdomains before enabling
includeSubDomains? - Am I avoiding preload until the domain is truly ready for a permanent HTTPS-only posture?
- Do I understand that HSTS over HTTP is ignored?
- Am I rolling out max-age gradually if the environment is complex?
If you want a fast sanity check on your live headers, run a scan at https://headertest.com?utm_source=hsts-guide&utm_medium=blog&utm_campaign=article-link.
HSTS is one of those controls that works brilliantly when you respect its operational consequences. Most failures aren’t cryptography failures. They’re inventory, ownership, and rollout failures dressed up as a header problem.