Redirecting HTTP to HTTPS isn’t quite enough. The very first request a browser makes, when someone types example.com or follows an old http:// link, still goes out unencrypted, and an attacker on the network can intercept it before the redirect happens (“SSL stripping”). HSTS (HTTP Strict Transport Security) tells browsers: for this domain, only ever use HTTPS. After one visit, the browser upgrades every request itself.
How it works
Your site sends a response header over HTTPS:
Strict-Transport-Security: max-age=31536000; includeSubDomains
The browser remembers it for max-age seconds, here one year. During that time:
- every
http://example.comlink or typed URL is internally rewritten tohttps://before any network request; - certificate errors become non-bypassable. The user can’t click “proceed anyway”.
Browsers ignore the header when it’s sent over plain HTTP.
Directives
| Directive | Meaning |
|---|---|
max-age=N |
How long (seconds) to remember. Required |
includeSubDomains |
Apply to every subdomain as well |
preload |
Signals consent to be included in browsers’ built-in HSTS preload list |
Add HSTS in Nginx
In the HTTPS server block (listen 443):
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
alwayssends it on error responses too.- Nginx’s
add_headerisn’t inherited into alocationblock that has its ownadd_headerdirectives. Repeat it there, or use an include file. See Nginx security headers.
Keep the HTTP → HTTPS redirect for first-time visitors:
server {
listen 80;
server_name example.com www.example.com;
return 301 https://$host$request_uri;
}
Add HSTS in Apache
<VirtualHost *:443>
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains"
…
</VirtualHost>
Enable mod_headers first: sudo a2enmod headers && sudo systemctl reload apache2.
Verify
curl -sI https://example.com | grep -i strict-transport-security
In Chrome, chrome://net-internals/#hsts lets you query, and for testing delete, a domain’s HSTS state.
The risks: why to roll out gradually
HSTS is a commitment the browser enforces, and you can’t take it back quickly:
- If your certificate expires or breaks, visitors can’t bypass the error, and the site is unreachable until it’s fixed.
- With
includeSubDomains, every subdomain must support HTTPS:mail.,dev., an oldintranet.that only speaks HTTP, an internal tool. They all become unreachable over HTTP. - Removing HSTS means sending
max-age=0, but browsers that cached the old policy only learn this when they visit again over HTTPS.
Safe rollout
- Make sure automatic certificate renewal works and is monitored. See Let’s Encrypt with Certbot and SSL expiry checks.
- Start small, without subdomains:
Strict-Transport-Security: max-age=300
- Check everything works, then increase step by step: 1 day (86400), 1 week, 1 month.
- Inventory all subdomains, make sure each has HTTPS, then add
includeSubDomains. - Settle at one year (31536000) or two years (63072000).
Preloading
Even with HSTS, the very first visit from a new browser is unprotected. The HSTS preload list is built into Chrome, Firefox, Safari and Edge, and protects listed domains from the first request.
Requirements, via hstspreload.org:
- a valid certificate, and HTTP redirecting to HTTPS on the same host;
- all subdomains served over HTTPS;
- the header on the base domain:
max-ageof at least one year, plusincludeSubDomainsandpreload.
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
Preloading is very hard to undo. Removal takes months to reach users’ browsers. Only preload domains where HTTPS everywhere is permanent. Some TLDs (like .dev and .app) are preloaded entirely.
Common questions
Do I still need the HTTP→HTTPS redirect? Yes, for first-time visitors and non-browser clients.
Does HSTS affect SEO? Not directly. HTTPS is a ranking signal, and HSTS just makes HTTPS consistent and slightly faster by skipping the redirect for returning visitors.
Behind Cloudflare or a CDN? You can set HSTS at the CDN (Cloudflare has an HSTS setting) or at the origin. Make sure it isn’t sent twice with different values.
Quick reference
| Task | Setting |
|---|---|
| Nginx | add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; |
| Apache | Header always set Strict-Transport-Security "…" |
| Test | curl -sI https://site | grep -i strict |
| Start safely | max-age=300, then increase |
| Remove | max-age=0 (served over HTTPS) |
Related: TLS configuration and SSL/TLS explained.