HSTS: test the Strict-Transport-Security header locally
HSTS tells a browser to use HTTPS for a host on later visits, and it counts only when the Strict-Transport-Security header arrives over HTTPS. We checked four variants of a small local server with curl: no header, a 300-second header, a header sent only over plain HTTP, and a correct header. Only the correct one produced no findings.
This is a scripted Python and curl experiment with downloadable source and responses. No AI agent or model ran the test.
How the HSTS header works and what the fixture covers
HTTP Strict Transport Security (HSTS) is defined in RFC 6797. A server sends a Strict-Transport-Security header in an HTTPS response, for example Strict-Transport-Security: max-age=31536000; includeSubDomains. A browser that accepts it remembers the host for max-age seconds and rewrites later http:// requests to https:// before sending them. The first visit is unprotected, because no policy exists yet.
The RFC sets a few rules that this fixture checks. max-age is required. includeSubDomains is optional (section 6.1.2). A max-age of zero tells the browser to forget the host (section 6.1.1). A server must not send the header over plain HTTP, and a browser must ignore it if it arrives that way (sections 7.2 and 8.1). The same section 7.2 says a server should answer plain HTTP with a permanent redirect to HTTPS.
The hstspreload.org page lists what the policy defends against when a browser enforces it: URL leaks on a clicked http:// link, protocol downgrades by an on-path attacker who rewrites the redirect, and cookie hijacking on the plain-HTTP request. For cookie injection, see our session fixation post.
- Target: one Python process with a plain-HTTP listener and a TLS listener, both on
127.0.0.1. The runner lets the operating system pick both ports. - Modes:
vulnerable,short,misplacedandfixed, described in the results table. - Requests: twelve curl requests (three per mode) and eleven parser checks on fixed strings.
- Certificate: self-signed, generated at start-up with the
opensslcommand in a temporary directory. The fixture deletes the private key once the server has loaded it. No key or certificate is in the evidence folder. - Data: one synthetic text page. The name
hsts.testis pinned to loopback withcurl --resolveand exists nowhere else.
The vulnerable modes are intentional and the fixture cannot bind another address. For a real site, agree hosts and stopping conditions with its owner first. The Excession Labs Acceptable Use Policy describes authorized use, and this article gives no permission to test another system.
Run the downloadable fixture
Download fixture.py and verify.py into one directory. You need Python 3.9 or later, openssl, and a curl build with HSTS support (curl --help all | grep hsts should list --hsts). The Python code uses only the standard library. Inspect the source, then run:
python3 verify.py --output rerun
The output directory must be new. The runner starts the fixture once per mode, sends the requests, saves every response and stops the server. Each curl call bypasses proxies, uses short timeouts and does not follow redirects. The layout matches our API authorization testing post.
Our recorded command was python3 verify.py --output recorded-run. It exited with {"checks": 55, "passed": true, "failures": []}, so every observed value matched the expected one, including the deliberately broken modes.
The run on 11 November 2026 used fixture and runner version 1.0.0, Python 3.15.2, curl 8.7.1 (SecureTransport) and OpenSSL 4.1.0 on macOS ARM64. Versions, UTC timestamps and source hashes are in results.json. A rerun will use different ports and dates.
Observed results for each HSTS header setup
| Mode | HTTPS response header | Plain HTTP response | Checker findings | curl cache after the HTTPS request |
|---|---|---|---|---|
vulnerable | none | 200, serves the page | missing-header, http-serves-content | empty |
short | max-age=300 | 301, no header | max-age-short, missing-includeSubDomains | hsts.test, expires in 299 s |
misplaced | none | 301, with the header | missing-header, header-over-http | empty |
fixed | max-age=31536000; includeSubDomains | 301, no header | none | .hsts.test, expires in 31536000 s |
Every mode returned 200 with the page over HTTPS, so each fixture still worked as a site. That is the positive control: a server that broke HTTPS would also have no header. The checker uses the one-year minimum from hstspreload.org as its max-age threshold. The RFC sets no minimum, and short uses the 300-second first stage of the rollout that hstspreload.org recommends, so it is a finding only against a preload-grade policy. That stage also sets includeSubDomains, which short omits.
The negative control is curl's own cache. After a plain HTTP request it stayed empty in all four modes. In misplaced mode the HTTP response carried a valid header and curl still did not store it, which matches the RFC rule that a client must ignore the header over insecure transport.
curl wrote .hsts.test with a leading dot only in fixed mode. curl documents the dot as the marker for a policy that includes subdomains. Its stored expiry matched max-age in both modes with a header: 299 seconds against 300, and 31536000 against 31536000.
We then replayed an http:// request with the filled cache. In short and fixed modes curl logged Switched from HTTP to HTTPS due to HSTS => https://hsts.test:50588/ (fixed run) and received no HTTP response. Exit code 35 is curl's TLS connect error. It sent a TLS handshake to the plain listener, because curl keeps the port when it upgrades, and the fixture uses two different ports. In the other two modes curl sent plain HTTP and got a response back (vulnerable and misplaced).
Verify the HSTS header before you report it
The misplaced mode is the setup a quick scan gets wrong. Its plain-HTTP redirect carries the header, so a scanner that looks for the header in the first response it gets can report HSTS as present. The recorded commands used ports 50582 (TLS) and 50583 (plain HTTP). Reformatted across lines:
curl --noproxy '*' --silent --show-error --include --http1.1 --insecure \
--connect-timeout 1 --max-time 3 --request GET \
--resolve hsts.test:50582:127.0.0.1 https://hsts.test:50582/
curl --noproxy '*' --silent --show-error --include --http1.1 --insecure \
--connect-timeout 1 --max-time 3 --request GET \
--resolve hsts.test:50583:127.0.0.1 http://hsts.test:50583/
The HTTPS response returned 200 and the page with no Strict-Transport-Security line. The plain HTTP response returned this (Server and Date headers omitted):
HTTP/1.0 301 Moved Permanently
Location: https://hsts.test:50582/
Content-Length: 0
Strict-Transport-Security: max-age=31536000; includeSubDomains
A decision rule for the finding:
- Follow redirects to the final HTTPS response and read the header there. A header on the plain-HTTP response is a defect to report. It is no evidence that HSTS is on.
- Parse the value instead of searching for the word.
max-age=abc, a repeatedmax-ageand a missingmax-ageall make a browser ignore the header, and the parser checks in the bundle record each one. - Compare
max-agewith the site's intent.max-age=0removes a policy. - Check
includeSubDomainsagainst a list of the subdomains that must work over HTTPS. RFC 6797 section 11.4 warns that unsecured services on subdomains or other ports can break when the directive is set.
Here the effect is one synthetic page that a browser could be made to request over plain HTTP on each first visit and after each expiry. The run rates no severity and attacks no browser. It shows what the server sends.
Send the header over TLS only and retest
The same source holds all four modes. The policy table in fixture.py has the vulnerable and fixed entries:
"vulnerable": {"https": None, "http": None, "http_redirects": False},
"fixed": {"https": "max-age=%d; includeSubDomains" % YEAR, "http": None, "http_redirects": True},
The handler sends the header for the scheme it is serving, and plain HTTP gets a 301 to HTTPS in the fixed mode:
if policy[scheme]:
self.send_header("Strict-Transport-Security", policy[scheme])
The redirect target uses a constant host name, not the request's Host header. Building it from the header would add the flaw in our host header injection post. The checker flags weak setups with two comparisons:
if parsed["max_age"] < PRELOAD_MIN:
findings.append("max-age-short")
if not parsed["include_subdomains"]:
findings.append("missing-includeSubDomains")
This changes the configuration of a toy server. In a real deployment the header goes into the web server or proxy configuration for HTTPS responses.
The retest ran against fresh servers on ports 50587 (TLS) and 50588 (plain HTTP). HTTPS returned 200 with Strict-Transport-Security: max-age=31536000; includeSubDomains. Plain HTTP returned 301 to https://hsts.test:50587/ with no header. The checker reported no findings and the page still loaded, which is the positive control for the fix.
hstspreload.org recommends raising max-age in stages, starting at 300 seconds, and waiting out each stage while you watch for broken pages. It also asks you to check every subdomain, including internal ones, first.
HSTS preload requirements
The first-visit gap is what the preload list addresses. As of 11 November 2026, hstspreload.org lists these requirements for the form:
- Serve a valid certificate.
- Redirect from HTTP to HTTPS on the same host if you listen on port 80.
- Serve all subdomains over HTTPS, including
wwwwhen a DNS record exists. - On the base domain, serve a header on HTTPS requests with
max-ageof at least 31536000,includeSubDomainsandpreload. A redirect served from the HTTPS site must carry the header too.
Our fixed mode omits the preload directive on purpose, and the fixture does not test any of the list requirements beyond the header values. The same page says preloading is not recommended and that its benefits are minimal compared with HSTS itself.
Inspect the evidence bundle
- Machine-readable results: each mode with expected and observed values, the parser checks, versions and hashes.
- Exact recorded curl commands: the ports belong to the finished run. Use the runner to start fresh servers.
- SHA-256 manifest: compare downloaded files. This is an integrity check, not independent attestation.
- Fixture instructions and limitations, server source and verification source.
The fixture is also on GitHub: web-security-fixtures.
All content is synthetic. The bundle holds raw responses, curl's verbose output and cache files. It holds no private key and no certificate.
What this experiment does not cover
curl does not enforce HSTS the way a browser does. Its --hsts cache stores hosts and upgrades requests inside curl, with no preload list and no certificate-error handling. This fixture tests what the server sends and what curl does with it. It does not test browser behaviour. We ran curl with -k because the certificate is self-signed, so nothing here tests certificate validity.
The run used one host name and no subdomains, so the effect of includeSubDomains is visible only as the dot in curl's cache. We did not test HTTP/2, redirect chains, duplicate headers, a CDN or proxy that rewrites headers, or any real domain. The one-year threshold in the checker is a policy choice for this article.
For a real site, compare the final HTTPS response of each hostname in scope and keep the raw headers. Excession Labs checks transport security headers like these in its security assessments.