HSTS Force HTTPS in Practice: Preload and Anti-Downgrade Configuration
HSTS forces the browser onto HTTPS for a set period and cuts SSL stripping and redirect-downgrade hijack at the root. Covers max-age, includeSubDomains, and preload; copy-paste Nginx and Apache; staged max-age rollout; preload-list submission.
Scope
HSTS forces the browser to use HTTPS only. A site may already have a cert, but a user typing http:// still sends one cleartext request, then the server 301/302s to HTTPS. That cleartext window is the SSL stripping entry: a MITM swaps the redirect for its own HTTP page. The user sees a normal site. Cookies and passwords already crossed the attacker in the clear.
HSTS (HTTP Strict Transport Security) closes that window. After the first HTTPS response with Strict-Transport-Security, the browser caches "this host is HTTPS-only". After that, typed http://, in-site cleartext links, or a forged redirect are rewritten to HTTPS before the request leaves the browser. Certificate errors are not click-through downgrades.
Turn HSTS on when: valid certs, all assets (images, JS, CSS, API subdomains) already speak HTTPS; you will stay on HTTPS with no planned rollback; every subdomain already has cert coverage. If the site still has hard-coded http:// refs, or some subdomain has no cert, `includeSubDomains` will black-hole that subdomain.
Prerequisites
- Valid cert including the intermediate chain. Accurate system clock.
- Nginx 1.9.0+ or Apache 2.4+, able to set response headers.
- No mixed content (browser console clean of Mixed Content).
- Subdomain inventory known; each subdomain already on HTTPS.
- Rollback plan: once HSTS is cached, deleting the response header does not revoke it immediately.
Internals
HSTS is one response header:
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
Three parameters, three jobs:
- max-age: seconds the browser keeps the policy. 31536000 is one year, the production default. It does not die when the server stops sending the header. It expires. Roll out in stages.
- includeSubDomains: policy covers every subdomain, including names not on the cert. Any http:// subdomain is rewritten to https://. No cert on that host means a hard error.
- preload: the site opts into the browser HSTS Preload List. The list ships with the browser. First visit never sends cleartext HTTP, even if the user has never hit the site. That closes the first-visit window.
301 vs HSTS execute in different places. 301 is a server response; the cleartext request already arrived; an on-path attacker can rewrite it. HSTS is a local browser policy; the URL is rewritten before the request leaves; there is no cleartext HTTP to hijack. They are not substitutes: keep 301 for old clients that do not speak HSTS, and stack HSTS on top.
Two easy misses. First: the header is honored only on HTTPS responses. Putting it on the http:// 301 is a no-op; the browser ignores it. Second: the first-visit gap dies only via the preload list. Raising max-age does not close it.
Procedure
Step 1: Confirm site-wide HTTPS and a complete chain
curl -sSI https://helixcyber.ai | head -n 5
curl -sS -o /dev/null -w '%{http_code} %{ssl_verify_result}\n' https://helixcyber.ai
openssl s_client -connect helixcyber.ai:443 -servername helixcyber.ai </dev/null 2>/dev/null | openssl x509 -noout -dates -subject
`ssl_verify_result` must be 0. Non-zero: fix the cert before HSTS. `includeSubDomains` plus a bad chain means the browser blocks and the user cannot bypass.
Step 2: Add the HSTS header in Nginx
Put this in the 443 server block. 80 is a no-op.
server {
listen 443 ssl http2;
server_name helixcyber.ai;
ssl_certificate /etc/nginx/ssl/example.com.fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/example.com.key;
# step 1: probe with 300s
add_header Strict-Transport-Security "max-age=300" always;
location / {
root /var/www/html;
index index.html;
}
}
`always` emits the header on 4xx/5xx too, so error pages do not leak a cleartext path. `max-age=300` is the canary; a mistake expires in 5 minutes.
If multiple Nginx hops (CDN or SLB in front), set the header on the public edge. Origin `add_header` is just another response header; whether it survives depends on the edge.
Step 3: Add the HSTS header in Apache
<VirtualHost *:443>
ServerName helixcyber.ai
SSLEngine on
SSLCertificateFile /etc/httpd/ssl/example.com.crt
SSLCertificateKeyFile /etc/httpd/ssl/example.com.key
Header always set Strict-Transport-Security "max-age=300"
</VirtualHost>
Enable headers first: `a2enmod headers` (Debian/Ubuntu) or confirm mod_headers in httpd.conf (RHEL/CentOS).
Step 4: Stage max-age up, then add subdomains
Use this cadence. Each stage observes ≥24h. Watch mobile apps, WeChat in-app browser, old IE, and third-party callback URLs:
- Day 1: `max-age=300`
- Day 2: `max-age=86400` (one day)
- Day 4: `max-age=2592000` (30 days)
- Day 7: `max-age=63072000` (two years); consider includeSubDomains here
- Day 14: `max-age=31536000; includeSubDomains; preload`
Confirm every subdomain is 200 with a valid cert:
for h in api static cdn mail; do
echo -n "$h.example.com "
curl -sS -o /dev/null -w '%{http_code} verify=%{ssl_verify_result}\n' "https://$h.example.com/" || echo FAIL
done
Step 5: Keep 301 as fallback
HSTS only covers browsers that already cached the policy. Crawlers and old clients still need a server-side force:
server {
listen 80;
server_name example.com helixcyber.ai;
# 301 with a full URL; relative redirects get hijacked
return 301 https://helixcyber.ai$request_uri;
}
Do not add Strict-Transport-Security here. Cleartext responses ignore it.
Step 6: Submit the HSTS preload list
Three hard gates or the submission is rejected: cert covers all subdomains; http:// and https:// serve the same host (no hop to another hostname); max-age at least 31536000 with includeSubDomains and preload. Then submit the domain at hstspreload.org and wait for the Chrome builtin list. Removal is measured in months. Do this last.
Verification
# 1. header present, params correct
curl -sSI https://helixcyber.ai | grep -i strict-transport-security
# 2. cleartext entry still redirects
curl -sSI https://helixcyber.ai | head -n 3
# 3. error responses also carry HSTS (always is live)
curl -sSI https://helixcyber.ai/__not_exist__ | grep -i strict-transport-security
# 4. local header check (curl does not cache HSTS; header only)
curl -sS -D - -o /dev/null https://helixcyber.ai
Browser check: Chrome `chrome://net-internals/#hsts`, query the host under Domain Security Policy. You will see dynamic (site-issued) or static (preload list). After first visit, disconnect the network and type http://; if the browser fails closed instead of redirecting, the policy is local. Keep scanning the header via Grafana/Prometheus or a security scanner HSTS check.
FAQ
Q1: Header is set, browser ignores it. Why?
Walk this order: (1) header lives in the port-80 server block; browsers ignore HSTS on cleartext; it must be in 443. (2) `add_header` without `always` on 4xx/5xx — Nginx omits it. (3) CDN/proxy in front rewrote headers; set it on the edge. (4) a plugin or middleware stripped custom headers. (5) you hit a subdomain; policy only covered the apex. Confirm the header the browser actually received with `curl -sSI`, then compare hop by hop.
Q2: includeSubDomains landed and one subdomain is dead. How do you recover?
Browsers that already cached the policy keep forcing https://. Fastest fix: issue a cert for that subdomain, or point it at a host that terminates TLS correctly. Deleting the response header does nothing to an already-cached policy. If that host cannot do HTTPS, resolve it to a box with a self-signed cert and a static notice page, or move internal-only hosts to a separate domain outside the parent policy. That is why you stage max-age from 300s.
Q3: Does HSTS break WeChat in-app browser or app API calls?
HSTS is in the browser engine. WeChat in-app (X5/Chromium) honors it. Risk: apps with hard-coded http:// APIs or pinned self-signed certs get blocked once includeSubDomains is on. Before rollout, client teams must confirm all API URLs are https:// and the server chain is complete (some Android versions fail harder on a missing intermediate). Keep one non-HSTS subdomain for internal systems during rollout so one change does not take down clients you do not control.
Summary
HSTS is cheap transport hardening: one header kills the SSL-stripping window on 301. Four landing rules: set it on the 443 block with `always`; grow max-age from 300s in stages, no instant two-year lock-in; enable includeSubDomains only after every subdomain has a cert; preload last, only when you will not roll back. Pair with 301 fallback, a complete cert chain, and continuous header checks. Then HTTPS is actually closed.