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:

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.

Annotated HSTS header and its testssl.sh findings The three tokens of a Strict-Transport-Security header each mapped downward to the specific finding testssl.sh reports for them. Strict-Transport-Security: max-age=63072000 includeSubDomains preload lifetime OK >= 180 days subdomains covered preload-eligible preload consent submit separately testssl.sh --hsts reports each token independently; a missing includeSubDomains downgrades the preload verdict even when max-age is long enough.
Each HSTS token produces its own testssl.sh finding; preload eligibility requires all three plus HTTPS on the apex.

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.

testssl.sh HSTS check sequence A vertical sequence from the testssl.sh client to the target server showing the HTTPS request, the header response, and the local grading of the parsed HSTS value. testssl.sh target :443 TLS handshake (openssl s_client) GET / HTTP/1.1 over TLS 200 + Strict-Transport-Security header parse tokens grade max-age locally no data leaves the client during grading
testssl.sh handshakes, fetches the header table over TLS, then grades the parsed HSTS value entirely on the client.

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.

CI severity gate decision tree A decision tree that routes each testssl.sh finding by severity to a passing or failing CI outcome. finding severity from out.json OK / INFO / LOW MEDIUM HIGH / CRITICAL keep scanning flag: review flag: review jq exit 0 green build jq exit 1 red build
The severity allow-list in the jq expression is the single gate: anything above LOW fails the build.

Edge Cases, Security Implications & Safe Rollback

Three traps recur when auditing HSTS with testssl.sh:

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.