[Blog](https://excessionlabs.ai/blog/) · 11 November 2026 · [Excession Labs](https://excessionlabs.ai/about/)

# HSTS: test the Strict-Transport-Security header locally

Source page: https://excessionlabs.ai/blog/hsts/

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.

On this page: [How HSTS works](https://excessionlabs.ai/blog/hsts/#scope) · [Reproduce the run](https://excessionlabs.ai/blog/hsts/#reproduce) · [Observed results](https://excessionlabs.ai/blog/hsts/#results) · [Verify the finding](https://excessionlabs.ai/blog/hsts/#finding) · [Fix and retest](https://excessionlabs.ai/blog/hsts/#fix) · [HSTS preload](https://excessionlabs.ai/blog/hsts/#preload) · [Evidence](https://excessionlabs.ai/blog/hsts/#evidence) · [Limits](https://excessionlabs.ai/blog/hsts/#limits)

## How the HSTS header works and what the fixture covers

HTTP Strict Transport Security (HSTS) is defined in [RFC 6797](https://www.rfc-editor.org/rfc/rfc6797). 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](https://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](https://excessionlabs.ai/blog/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`, `misplaced` and `fixed`, 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 `openssl` command 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.test` is pinned to loopback with `curl --resolve` and 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](https://excessionlabs.ai/aup/) describes authorized use, and this article gives no permission to test another system.

## Run the downloadable fixture

Download [fixture.py](https://excessionlabs.ai/blog/evidence/hsts/fixture.py) and [verify.py](https://excessionlabs.ai/blog/evidence/hsts/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](https://excessionlabs.ai/blog/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](https://excessionlabs.ai/blog/evidence/hsts/recorded-run/results.json). A rerun will use different ports and dates.

## Observed results for each HSTS header setup

Headers and findings from twelve local curl requests

| 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](https://curl.se/docs/hsts.html) 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](https://excessionlabs.ai/blog/evidence/hsts/recorded-run/vulnerable-http-with-cache.txt) and [misplaced](https://excessionlabs.ai/blog/evidence/hsts/recorded-run/misplaced-http-with-cache.txt)).

## 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](https://excessionlabs.ai/blog/evidence/hsts/recorded-run/misplaced-https.http) returned 200 and the page with no `Strict-Transport-Security` line. The [plain HTTP response](https://excessionlabs.ai/blog/evidence/hsts/recorded-run/misplaced-http.http) 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 repeated `max-age` and a missing `max-age` all make a browser ignore the header, and the parser checks in the bundle record each one.

- Compare `max-age` with the site's intent. `max-age=0` removes a policy.

- Check `includeSubDomains` against 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](https://excessionlabs.ai/blog/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`](https://excessionlabs.ai/blog/evidence/hsts/recorded-run/fixed-https.http). Plain HTTP returned [301 to `https://hsts.test:50587/`](https://excessionlabs.ai/blog/evidence/hsts/recorded-run/fixed-http.http) 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 `www` when a DNS record exists.

- On the base domain, serve a header on HTTPS requests with `max-age` of at least 31536000, `includeSubDomains` and `preload`. 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](https://excessionlabs.ai/blog/evidence/hsts/recorded-run/results.json): each mode with expected and observed values, the parser checks, versions and hashes.

- [Exact recorded curl commands](https://excessionlabs.ai/blog/evidence/hsts/recorded-run/commands.sh): the ports belong to the finished run. Use the runner to start fresh servers.

- [SHA-256 manifest](https://excessionlabs.ai/blog/evidence/hsts/SHA256SUMS): compare downloaded files. This is an integrity check, not independent attestation.

- [Fixture instructions and limitations](https://excessionlabs.ai/blog/evidence/hsts/README.txt), [server source](https://excessionlabs.ai/blog/evidence/hsts/fixture.py) and [verification source](https://excessionlabs.ai/blog/evidence/hsts/verify.py).

The fixture is also on GitHub: [web-security-fixtures](https://github.com/Excession-Labs/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](https://excessionlabs.ai/security-assessment/).
