If you serve HTTPS and you’re not using HSTS, you still have a weak first step.
That sounds harsh, but it’s true. A site can have a perfectly valid TLS setup and still be vulnerable to downgrade tricks that push users onto plain HTTP before HTTPS ever gets a chance. HSTS fixes that by telling browsers: “for this site, never use HTTP again.”
That one rule shuts down a whole class of annoying and very real attacks.
What HSTS actually does
HSTS stands for HTTP Strict Transport Security. It’s a response header:
Strict-Transport-Security: max-age=31536000; includeSubDomains
When a browser receives that header over a valid HTTPS connection, it stores a policy for your domain. For the duration of max-age, the browser will:
- automatically convert
http://example.comtohttps://example.com - refuse to bypass TLS certificate errors for that domain
- optionally apply the rule to all subdomains if
includeSubDomainsis set
That means after the browser learns the HSTS policy once, it stops making insecure HTTP requests to your site.
The downgrade problem HSTS is designed to stop
Without HSTS, the first request is often still vulnerable.
A user types:
example.com
The browser may try:
http://example.com
Your server then redirects:
HTTP/1.1 301 Moved Permanently
Location: https://example.com
That redirect is normal, but the first request was plain HTTP. If an attacker is on the same network, they can interfere before the browser reaches HTTPS.
Classic attack flow:
- User connects on public Wi-Fi
- Attacker intercepts the initial HTTP request
- Instead of letting the browser upgrade to HTTPS, attacker serves a fake HTTP version
- User stays on insecure HTTP
- Attacker reads or modifies traffic
That’s the basic HTTPS downgrade or SSL stripping pattern.
If HSTS is already cached, the browser never sends that first HTTP request. It upgrades locally before anything hits the network. That’s the whole win.
HSTS does not protect the very first visit
This is the part people gloss over.
HSTS only works after the browser has seen the header once. If it’s a brand-new visitor, the first connection can still be attacked unless the domain is preloaded.
So there are really two protection levels:
- Regular HSTS: protects repeat visits after the browser learns the policy
- HSTS preload: protects even the first visit because the browser ships with your domain baked into its preload list
If you want strong downgrade resistance, preload is the endgame. But don’t rush into it casually.
The header fields that matter
A typical production header looks like this:
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
max-age
How long the browser should remember the policy, in seconds.
Examples:
max-age=300→ 5 minutesmax-age=86400→ 1 daymax-age=31536000→ 1 year
For rollout, I usually start small and increase gradually.
includeSubDomains
Applies the rule to all subdomains too.
That’s great if every subdomain supports HTTPS correctly. It’s dangerous if you still have forgotten legacy hosts, old admin panels, or random DNS entries pointing somewhere weird.
preload
Signals intent to join browser preload lists.
This token alone does nothing unless you actually submit the domain for preload and meet browser requirements, but it’s required for preload eligibility.
A safe rollout strategy
The most common HSTS mistake is enabling a one-year policy with includeSubDomains on a messy domain and then discovering some forgotten subdomain breaks.
Use a staged rollout instead.
Stage 1: short cache
Strict-Transport-Security: max-age=300
Watch for breakage.
Stage 2: longer cache
Strict-Transport-Security: max-age=86400
Then move to weeks or months.
Stage 3: production policy
Strict-Transport-Security: max-age=31536000; includeSubDomains
Stage 4: consider preload
Only after you’ve verified:
- HTTPS works on the apex domain
- HTTPS works on
wwwif you use it - all subdomains are HTTPS-capable
- HTTP redirects cleanly to HTTPS everywhere
- certificates are valid and renewed reliably
If you want a quick check of your current headers, run a scan at headertest.com.
Nginx configuration
Nginx is straightforward, but one detail trips people up: use always so the header is added on error responses too.
server {
listen 443 ssl http2;
server_name example.com www.example.com;
ssl_certificate /etc/ssl/example/fullchain.pem;
ssl_certificate_key /etc/ssl/example/privkey.pem;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
location / {
proxy_pass http://app;
}
}
server {
listen 80;
server_name example.com www.example.com;
return 301 https://$host$request_uri;
}
If you’re testing rollout, lower the max-age first:
add_header Strict-Transport-Security "max-age=300" always;
Apache configuration
For Apache, make sure mod_headers is enabled.
<VirtualHost *:443>
ServerName example.com
ServerAlias www.example.com
SSLEngine on
SSLCertificateFile /etc/ssl/example/cert.pem
SSLCertificateKeyFile /etc/ssl/example/key.pem
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains"
DocumentRoot /var/www/html
</VirtualHost>
<VirtualHost *:80>
ServerName example.com
ServerAlias www.example.com
Redirect permanent / https://example.com/
</VirtualHost>
Same advice here: don’t jump straight to a one-year policy unless you’re sure.
Express / Node.js
If you run Express directly, you can set the header yourself:
const express = require('express');
const app = express();
app.use((req, res, next) => {
if (req.secure) {
res.setHeader(
'Strict-Transport-Security',
'max-age=31536000; includeSubDomains'
);
}
next();
});
app.get('/', (req, res) => {
res.send('Hello over HTTPS');
});
app.listen(3000);
If Express is behind a reverse proxy, make sure req.secure works correctly:
app.set('trust proxy', 1);
Or use Helmet, which is what I’d usually do in a real app:
const express = require('express');
const helmet = require('helmet');
const app = express();
app.use(
helmet.hsts({
maxAge: 31536000,
includeSubDomains: true,
preload: false
})
);
app.get('/', (req, res) => {
res.send('Secure app');
});
app.listen(3000);
Official docs for Helmet are here: https://helmetjs.github.io/
CDN and load balancer gotchas
A lot of teams configure HSTS in the app and assume they’re done. Then a CDN, edge worker, or load balancer strips or overrides the header.
Check the actual response seen by the browser, not just your origin server.
Also watch for these edge cases:
- CDN serves some paths without HSTS
- static asset hostnames don’t support HTTPS
- old subdomains are still reachable over HTTP
- redirect chains bounce across multiple hosts before landing on HTTPS
HSTS is domain policy. Messy hostnames ruin clean policies.
Verifying that HSTS is working
You should verify three things:
1. The header is present on HTTPS responses
curl -I https://example.com
Expected output:
Strict-Transport-Security: max-age=31536000; includeSubDomains
2. HTTP redirects to HTTPS
curl -I http://example.com
Expected output:
HTTP/1.1 301 Moved Permanently
Location: https://example.com/
3. Browser has stored the policy
Use browser dev tools or browser HSTS inspection pages during testing. This matters because the browser behavior is the real enforcement point.
Common mistakes
I’ve seen the same HSTS failures over and over.
Setting HSTS on HTTP responses
Browsers ignore HSTS sent over plain HTTP. It must be delivered over valid HTTPS.
Enabling includeSubDomains too early
This is the one that bites hardest. If one forgotten subdomain doesn’t support HTTPS, users can get locked into failures.
Using a huge max-age during testing
If you send:
Strict-Transport-Security: max-age=31536000
you’ve told browsers to remember that policy for a year. Rolling it back is not immediate for users who already cached it.
Assuming preload is easy to undo
Preload is powerful and annoying to reverse. Treat it like a one-way door unless you have a very good reason and a clean domain inventory.
Forgetting certificate error behavior
HSTS makes certificate problems harsher by design. Users can’t click through warnings on HSTS hosts. That’s a feature, but it means your certificate automation needs to be solid.
Disabling or reducing HSTS
If you need to turn it down, serve:
Strict-Transport-Security: max-age=0
That tells browsers to delete the cached policy.
But this only works after the browser successfully reaches your site over HTTPS and receives the new header. If you broke HTTPS badly, users may still be stuck until the cached policy expires or you restore valid TLS.
That’s why careful rollout matters.
When HSTS is worth it
Honestly, almost always.
If your site is fully HTTPS and you control your subdomains, HSTS is low effort and high value. It closes the downgrade gap between “supports HTTPS” and “actually forces HTTPS in the browser.”
That distinction matters. Attackers love soft edges, and the initial HTTP request is one of them.
My practical recommendation:
- Redirect all HTTP to HTTPS
- Deploy HSTS with a low
max-age - Increase to one year once stable
- Add
includeSubDomainsonly when you’ve audited the whole domain - Move to preload only when you’re absolutely sure
That’s how you prevent downgrade attacks without creating a self-inflicted outage.