Testing HSTS and TLS with testssl.sh
This guide is part of the Auditing Headers with curl, openssl and testssl.sh reference. testssl.sh is a single self-contained bash script that opens raw sockets, drives openssl, and reports the exact TLS and header facts your origin puts on the wire. Where curl -sI shows you one response, testssl.sh walks the full protocol and cipher matrix and checks the security headers in the same pass — which makes it the right tool for auditing HSTS together with the TLS foundation it depends on.
Configuration Syntax & Exact Values
testssl.sh does not configure your server — it reads it. The value it audits is the Strict-Transport-Security response header your origin emits. Get that string exactly right first, because every finding downstream is a judgement against it. The canonical production value is a single line with three tokens:
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
The directive breakdown, in the order testssl.sh parses and grades them:
max-age=63072000— the lifetime in seconds (here two years). This is the only mandatory token.testssl.shflags anymax-ageunder 15552000 (180 days) because the HSTS preload list rejects anything shorter. Amax-age=0is treated as an explicit un-set, not a warning.includeSubDomains— extends the policy to every subdomain. Required for preload eligibility.testssl.shreports its presence or absence explicitly.preload— your consent to ship the host in browsers’ hard-coded preload list. It has no effect until you submit the domain;testssl.shreports the token but cannot know your submission status.
The tokens are semicolon-separated, order-independent, and case-insensitive on the directive names. max-age must be an unquoted integer. The annotated figure below maps each token to the finding it produces.
Server-Side Configuration
testssl.sh audits whatever these directives emit, so set the header once, correctly, at the edge. Each block below emits the exact canonical value above. The always-equivalent flag matters on every platform: without it the header is dropped on error and redirect responses, which are precisely the responses testssl.sh --headers will probe and fail on.
Nginx
# add_header inside the TLS server block. The `always` flag is mandatory:
# without it the header is omitted on 4xx/5xx and redirect responses.
server {
listen 443 ssl;
server_name example.com;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
}
See the Nginx security headers configuration reference for how add_header inheritance drops the header inside nested location blocks — a gap testssl.sh will surface.
Apache
# Header always set — the `always` keyword attaches the header to the
# internal error-response table too, so 500 pages still carry HSTS.
<VirtualHost *:443>
ServerName example.com
Header always set Strict-Transport-Security "max-age=63072000; includeSubDomains; preload"
</VirtualHost>
The Apache htaccess and VirtualHost hardening guide covers the always condition in depth.
Cloudflare
{
"comment": "Managed Transform sets HSTS at the edge; enabled must be true or the header never leaves Cloudflare.",
"security_header": {
"strict_transport_security": {
"enabled": true,
"max_age": 63072000,
"include_subdomains": true,
"preload": true,
"nosniff": true
}
}
}
Point testssl.sh at the Cloudflare hostname, not the origin, or you audit the wrong server. See Cloudflare page rules and headers.
Django / FastAPI
# Django: SecurityMiddleware emits HSTS on every response it processes,
# including error responses, so no per-view flag is needed.
SECURE_HSTS_SECONDS = 63072000
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = True
SECURE_SSL_REDIRECT = True
The Django and FastAPI security middleware reference details the middleware ordering that guarantees the header on redirects.
Diagnostic & Verification Steps
Install the script from source — the packaged versions lag the OpenSSL bundle it ships with.
git clone --depth 1 https://github.com/testssl/testssl.sh.git
cd testssl.sh
./testssl.sh --version
# testssl.sh 3.2 from https://testssl.sh/
Audit HSTS on its own
--hsts runs only the HSTS-related header checks. It is fast because it skips the full cipher enumeration.
./testssl.sh --hsts https://example.com
# Testing HTTP header response @ "/"
#
# Strict Transport Security 2 years=63072000 s, includeSubDomains, preload
Read that line token by token. 2 years=63072000 s confirms the lifetime is long enough — testssl.sh translates the seconds into a human span and only prints it in the “good” colour when it clears 180 days. includeSubDomains and preload echo the tokens it found. A weak configuration reports the deficiency inline:
./testssl.sh --hsts https://weak.example.com
# Strict Transport Security 1 day=86400 s, no includeSubDomains, no preload
# (HSTS max-age is too short: <15552000 seconds)
Audit all security headers
--headers (alias -h in older builds is --help; use the long form) runs the full response-header suite — HSTS plus Content-Security-Policy, X-Frame-Options, and the rest.
./testssl.sh --headers https://example.com
# Testing HTTP header response @ "/"
#
# HTTP Status Code 200 OK
# Strict Transport Security 2 years=63072000 s, includeSubDomains, preload
# Content Security Policy default-src 'self'
# X-Frame-Options DENY
# X-Content-Type-Options nosniff
# Referrer Policy strict-origin-when-cross-origin
# Permissions Policy --
# Reverse Proxy Banner --
A -- means the header is absent. The sequence below shows what testssl.sh actually does on the wire for the HSTS check: it forces an HTTPS request, reads the response header table, then grades the parsed value.
JSON output for automation
--jsonfile (or --jsonfile-pretty) writes structured findings you can assert on. Combine it with --severity to raise the noise floor.
./testssl.sh --hsts --severity LOW \
--jsonfile-pretty result.json https://example.com
# ... writes result.json
Each finding is an object with id, severity (OK, LOW, MEDIUM, HIGH, CRITICAL) and a finding string:
[
{
"id": "HSTS",
"severity": "OK",
"finding": "Strict Transport Security: 63072000 s = 2 years, includeSubDomains, preload"
},
{
"id": "HSTS_time",
"severity": "OK",
"finding": "HSTS max-age is 63072000 (>= 15552000 seconds)"
}
]
Assert on it with jq — exit non-zero if any finding is worse than LOW:
jq -e 'all(.[]; .severity as $s
| ["OK","INFO","LOW"] | index($s) )' result.json > /dev/null \
&& echo "PASS" || echo "FAIL: high-severity finding"
# PASS
CI wiring
Run the script pinned to a tag, mask the target as an environment variable, and let the JSON drive the exit code. This block is a complete GitHub Actions step.
- name: TLS and HSTS audit
run: |
git clone --depth 1 --branch v3.2 \
https://github.com/testssl/testssl.sh.git ts
./ts/testssl.sh --quiet --hsts --headers \
--severity LOW --jsonfile-pretty out.json "$TARGET"
jq -e 'all(.[]; ["OK","INFO","LOW"] | index(.severity))' \
out.json
# Expected on a passing origin: jq exits 0, the step is green.
# On a short max-age: HSTS_time is MEDIUM, jq exits 1, the step fails.
The decision tree below is the exact logic the jq gate applies to each finding.
Edge Cases, Security Implications & Safe Rollback
Three traps recur when auditing HSTS with testssl.sh:
- Auditing HTTP instead of HTTPS. HSTS is only ever sent over HTTPS; a compliant server MUST NOT emit the header on a plaintext response, and browsers ignore it if it arrives over HTTP. If you point
testssl.shathttp://example.comyou will see no HSTS finding and may conclude it is missing. Always audit thehttps://URL, and separately confirm the plaintext port issues a301to HTTPS.testssl.shfollows the redirect and audits the final HTTPS response, but the finding reflects that hop — check the--headersHTTP Status Codeline to confirm you landed on the intended URL. - max-age too short to preload, silently. A server can ship
max-age=86400and pass a naive “is HSTS present?” check while being ineligible for preload and offering only a one-day protection window.testssl.shcatches this precisely:HSTS_timedrops toMEDIUMbelow 15552000 seconds. Never assert only on presence; assert on the graded lifetime. - CDN or proxy overwrites the origin header. When a CDN sits in front, the header
testssl.shreads is whatever the edge emits, which can differ from the origin. AManaged Transformor proxy rule can inject, strip, or shorten HSTS. Audit both the edge hostname and, via--ipor aHost-header request to the origin IP, the origin itself.
Rollback is the sharp edge of HSTS specifically. Once a browser has cached a long max-age, it will refuse plaintext and refuse to bypass certificate errors for the full lifetime — there is no way to reach those clients to retract it. The safe path is to prove the value in staging, roll out with a short max-age, and lengthen only after testssl.sh confirms the header on every response class.
# Emergency retraction: emit max-age=0 over HTTPS. Browsers that
# successfully connect will clear their cached policy. Verify:
./testssl.sh --hsts https://example.com
# Strict Transport Security HSTS max-age is set to 0 (disabled)
max-age=0 only reaches clients that can still complete the TLS handshake; a client already inside a broken-certificate lockout never sees it. That is why you never ship a multi-year max-age and preload until the shorter value is verified in production.
Conclusion
Audit in stages. Prove the exact header string against a staging origin with ./testssl.sh --hsts, roll it to production with a short max-age (start around 300 seconds, or use report-only equivalents for the headers that support them), and let testssl.sh --headers in CI confirm the value on 2xx, redirect and error responses before you extend the lifetime to two years and submit for preload. The full-production step — long max-age, includeSubDomains, preload — is the last move, taken only once the JSON gate is green.
Frequently Asked Questions
Why does testssl.sh report no HSTS header even though curl shows one?
You almost certainly pointed the two tools at different URLs or ports. testssl.sh audits HTTPS and ignores any Strict-Transport-Security seen over plaintext, because browsers do too. Re-run with the explicit https:// URL and confirm the HTTP Status Code line shows the response you expect.
What max-age does testssl.sh consider strong enough?
It flags any max-age below 15552000 seconds (180 days) because that is the preload list minimum. The HSTS_time finding is OK at or above that threshold and MEDIUM below it. The common production value of 63072000 (two years) clears it comfortably.
Can I run only the HSTS check to keep CI fast?
Yes. --hsts runs the HSTS header checks without the full cipher and protocol enumeration, so it finishes in seconds. Add --headers when you also want CSP, X-Frame-Options and the rest graded in the same run.
How do I fail a build only on serious findings?
Write JSON with --jsonfile-pretty and pipe it through jq, allow-listing the OK, INFO and LOW severities and exiting non-zero on anything above. testssl.sh itself exits 0 on completion regardless of findings, so the jq gate is what enforces your policy.
Does testssl.sh send my results anywhere? No. It runs entirely locally, opening sockets to the target you name and grading the responses on your own machine. Nothing about the target or its findings is transmitted to a third party, which is why it is safe to run against internal hosts.