Auditing Headers with curl, openssl and testssl.sh
This guide is part of the Security Header Auditing & Compliance reference and treats the command line as the authoritative auditing surface. A grading service tells you a letter; curl, openssl, testssl.sh and nmap tell you the exact bytes on the wire, on the exact path, method and status code you ask about. This is the verification layer that sits beneath every other check: the security header audit checklist defines what to assert, the commands here prove it, and the same one-liners drop straight into automated header scanning in CI/CD once you trust them by hand.
Threat Model & Protocol Mechanics
The threat this workflow addresses is not an attacker — it is a false sense of safety. Trusting a single scanner is the failure mode. A hosted grader fetches one URL, with one method, follows redirects however it chooses, and reports a verdict. That verdict is true for that request. It says nothing about the ten other request shapes your origin actually serves, and it is exactly those unaudited shapes where headers go missing.
Security headers are emitted by server logic, and server logic branches. The same origin commonly returns different headers depending on:
- Path.
add_headerin an Nginxlocationblock replaces inherited headers;/api/can ship a header set entirely different from/. The Nginx security headers configuration reference covers why inheritance silently drops headers in nested locations. - HTTP method. A
GETand anOPTIONS(CORS preflight) to the same URL traverse different code paths. Headers set in a view but not in middleware appear on one and not the other. - Status code. This is the single most exploited gap. Without the
alwaysflag on Nginx or Apache, security headers are emitted only on 2xx/3xx responses. The 404 page, the 500 error, and the 401 challenge — the responses an attacker can most easily induce — ship bare. An attacker who can force a 500 and frame the result has defeated your framing control. - Redirects. The
301fromhttp://tohttps://, and the301from apex towww, are responses in their own right. Headers must be present on the final 200, but a redirect chain that strips HSTS on the hop leaves the upgrade window open.
The discipline is therefore: audit the responses a scanner skips. Probe error pages directly, walk every redirect hop, and vary the method. The command line is the only tool that lets you control each of these axes precisely and read the raw result.
Tooling Workflows
Every block below is runnable as written. Replace example.com with your target. The expected output is shown as a comment so you know what a passing result looks like.
curl: the header inspector
curl -sI issues a HEAD request and prints only the response headers. -s silences the progress meter, -I requests headers only. This is the fastest way to read exactly what the server sends.
curl -sI https://example.com
# HTTP/2 200
# strict-transport-security: max-age=63072000; includeSubDomains; preload
# content-security-policy: default-src 'self'
# x-frame-options: DENY
# x-content-type-options: nosniff
# referrer-policy: strict-origin-when-cross-origin
Note the header names are lowercase: this is an HTTP/2 connection, and HTTP/2 mandates lowercased field names on the wire. Always match case-insensitively. Isolate a single header:
curl -sI https://example.com | grep -i strict-transport-security
# strict-transport-security: max-age=63072000; includeSubDomains; preload
Pull the full security-header set in one pass:
curl -sI https://example.com | grep -iE 'strict-transport|content-security|x-frame|x-content-type|referrer-policy|permissions-policy|cross-origin-'
# strict-transport-security: max-age=63072000; includeSubDomains; preload
# content-security-policy: default-src 'self'; frame-ancestors 'none'
# x-frame-options: DENY
# x-content-type-options: nosniff
# referrer-policy: strict-origin-when-cross-origin
# permissions-policy: geolocation=(), camera=()
-I sends HEAD, which some application stacks route differently from GET. When a header is set on the GET path only, switch to a header-only GET with -sD - -o /dev/null: dump headers to stdout, discard the body.
curl -s -D - -o /dev/null https://example.com
# HTTP/2 200
# ...full GET response headers...
Follow the redirect chain with -L, and use -D - to print the headers of every hop, including the intermediate redirects a scanner may collapse:
curl -sL -D - -o /dev/null http://example.com
# HTTP/1.1 301 Moved Permanently
# location: https://example.com/
# HTTP/2 200
# strict-transport-security: max-age=63072000; includeSubDomains; preload
Audit the responses a scanner skips — the error pages — by requesting a path you know does not exist and a path that triggers an error:
curl -sI https://example.com/this-path-does-not-exist
# HTTP/2 404
# x-content-type-options: nosniff
# x-frame-options: DENY
If those headers are absent on the 404 while present on the 200, the origin is missing the always flag. Force a specific HTTP version to confirm behavior across protocols, since HTTP/1.1 preserves header casing while HTTP/2 lowercases it:
curl -sI --http1.1 https://example.com | grep -i x-frame-options
# X-Frame-Options: DENY
curl -sI --http2 https://example.com | grep -i x-frame-options
# x-frame-options: DENY
Vary the method to catch middleware-vs-view divergence — an OPTIONS preflight is a common blind spot:
curl -s -X OPTIONS -D - -o /dev/null https://example.com/api/resource
# HTTP/2 204
# access-control-allow-origin: https://app.example.com
# (note: security headers set only in a view may be absent here)
openssl s_client: the TLS and handshake auditor
openssl s_client opens a raw TLS connection and lets you inspect the certificate chain and handshake before any HTTP is exchanged. Because HSTS hard-fails on any certificate error with no click-through, a clean chain is a prerequisite for safe enforcement — verify it here first.
openssl s_client -connect example.com:443 -servername example.com < /dev/null 2>/dev/null \
| grep -E 'Verify return code|subject=|issuer='
# subject=CN=example.com
# issuer=C=US, O=Let's Encrypt, CN=R11
# Verify return code: 0 (ok)
Verify return code: 0 (ok) is the only passing result. The -servername flag sends SNI, which is mandatory on shared-IP hosts; omit it and you may receive a default certificate that fails to match. Inspect the certificate’s validity window and Subject Alternative Names — a SAN that does not cover a subdomain is the top cause of includeSubDomains lockouts:
openssl s_client -connect example.com:443 -servername example.com < /dev/null 2>/dev/null \
| openssl x509 -noout -dates -ext subjectAltName
# notBefore=Apr 1 00:00:00 2026 GMT
# notAfter=Jun 30 23:59:59 2026 GMT
# X509v3 Subject Alternative Name:
# DNS:example.com, DNS:www.example.com
You can also drive a full HTTP request through the established TLS socket to confirm the header arrives over a verified connection (RFC 6797 requires browsers to ignore HSTS unless it was delivered over an error-free HTTPS connection):
printf 'HEAD / HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\n\r\n' \
| openssl s_client -quiet -connect example.com:443 -servername example.com 2>/dev/null \
| grep -i strict-transport-security
# strict-transport-security: max-age=63072000; includeSubDomains; preload
Confirm the negotiated protocol and cipher to ensure no legacy downgrade is on offer:
openssl s_client -connect example.com:443 -servername example.com < /dev/null 2>/dev/null \
| grep -E 'Protocol|Cipher'
# Protocol : TLSv1.3
# Cipher : TLS_AES_256_GCM_SHA384
testssl.sh: full TLS plus HSTS reporting
testssl.sh is a single bash script that runs an exhaustive TLS audit — protocols, ciphers, vulnerabilities (Heartbleed, ROBOT, BEAST), and it parses security headers including HSTS in one report. It needs no installation beyond cloning the repo or running the container.
testssl.sh --headers https://example.com
# Testing HTTP header response @ "/"
# HTTP Status Code 200 OK
# Strict Transport Security 730 days=63072000 s, just this domain
# Security headers X-Frame-Options DENY
# X-Content-Type-Options nosniff
# Content-Security-Policy default-src 'self'
# Reverse Proxy banner --
For the complete TLS picture plus headers in one shot, run the default profile and write a parseable artifact for your records:
testssl.sh --quiet --color 0 --jsonfile audit.json https://example.com
# (writes audit.json with one finding object per check; severity field gates CI)
The HSTS line decodes max-age into days and states whether the scope is “just this domain” or includes subdomains — a faster read than decoding the raw header by eye. Run it against a staging host before tightening enforcement, because it will also flag an expiring certificate or weak protocol that would break HSTS once it hard-fails.
nmap: scripted header scanning at scale
nmap with the http-security-headers NSE script reports present and missing headers, useful when sweeping a range of hosts rather than a single URL.
nmap -p 443 --script http-security-headers example.com
# PORT STATE SERVICE
# 443/tcp open https
# | http-security-headers:
# | Strict_Transport_Security:
# | Header: Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
# | X_Frame_Options:
# | Header: X-Frame-Options: DENY
# | Content_Security_Policy:
# |_ Header: Content-Security-Policy: default-src 'self'
A header listed without a value, or absent from the output entirely, is the signal to drill in with curl on that host. nmap scans the default path only, so treat it as a first-pass sweep, not the per-path audit.
A reusable bash audit script
Wrap the checks above into a script that loops a URL list and exits non-zero on the first failure, so the same logic runs at your desk and in the pipeline. This is the bridge into automated header scanning in CI/CD.
#!/usr/bin/env bash
# audit-headers.sh — assert required security headers on a list of URLs.
set -euo pipefail
REQUIRED=(
"strict-transport-security"
"content-security-policy"
"x-content-type-options"
"x-frame-options"
"referrer-policy"
)
fail=0
while IFS= read -r url; do
[ -z "$url" ] && continue
# Capture headers of the FINAL response after following redirects.
headers=$(curl -sIL "$url")
status=$(printf '%s\n' "$headers" | grep -iE '^HTTP/' | tail -1)
echo "== $url ($status)"
for h in "${REQUIRED[@]}"; do
if printf '%s\n' "$headers" | grep -qi "^$h:"; then
echo " ok $h"
else
echo " MISS $h"
fail=1
fi
done
done < urls.txt
exit "$fail"
Run it against a newline-delimited urls.txt:
chmod +x audit-headers.sh && ./audit-headers.sh
# == https://example.com (HTTP/2 200)
# == https://example.com (HTTP/2 200)
# ok strict-transport-security
# ok content-security-policy
# ok x-content-type-options
# ok x-frame-options
# ok referrer-policy
# == https://api.example.com/health (HTTP/2 200)
# MISS content-security-policy
# (exit code 1)
The non-zero exit on a miss is what makes the script a gate rather than a report.
Verification & Diagnostic Workflows
Reading the output correctly is the skill that separates a real audit from a green checkmark.
curl -sIfirst lineHTTP/2 200— the request reached the origin over HTTP/2 and returned 200. AHTTP/1.1 301means you are reading a redirect, not the destination; add-Land re-read the final hop.- Lowercased header names — expected on HTTP/2. It is not a misconfiguration and browsers treat field names case-insensitively. Never assert on case.
openssl ... Verify return code: 0 (ok)— the chain validated against the system trust store. Any non-zero code (20unable to get local issuer,10expired,62hostname mismatch) means the chain is broken and HSTS would lock users out on enforcement.testssl.shHSTS line “just this domain” — the policy omitsincludeSubDomains; subdomains are unprotected. “includes subdomains” confirms full scope.- nmap header with no value — the script saw the header name but no value, or the host returned it on a non-default path; confirm with
curlbefore trusting the verdict. - A header present on
/but absent on/nonexistent(404) — thealwaysflag is missing. The error page an attacker can induce ships unprotected.
For an end-to-end browser-side confirmation, open DevTools → Network, select the document request, and read the Response Headers pane; it shows the same bytes curl reports, which is useful when a CDN injects headers only for real browser requests carrying specific User-Agent or Accept values.
Troubleshooting, Misconfigurations & Safe Rollback
These commands are read-only — they observe, they do not change server state, so there is nothing to roll back. The traps are in interpreting the output.
- Headers look different in
curlthan in the browser → the body was compressed andcurldid not request it, or a CDN varies headers onUser-Agent/Accept-Encoding. Re-run withcurl -sI -H 'Accept-Encoding: gzip' -H 'User-Agent: Mozilla/5.0'to mimic a browser request; gzip affects the body, not header presence, butVary-driven CDN logic can change which headers ship. - HTTP/2 lowercased names break a
grep→ you matched with a capital letter (grep 'X-Frame'). Always usegrep -i. - A header is missing on first run, present on the next → a CDN cache served a stale object on the first request. Bust the cache with a cache-busting query string (
?cb=$RANDOM) or read thecf-cache-status/ageheader to confirm you are seeing origin behavior, not edge cache. The interaction with Cache-Control and Clear-Site-Data governs how long a stale header lingers. - Auth-gated endpoint returns 401/302 to a login page → you audited the challenge, not the protected resource. Pass a session with
curl -sI -H 'Cookie: session=...'or-H 'Authorization: Bearer ...'so you read the real protected response and its headers. opensslreportsverify error:num=20but the browser is happy → the chain is missing an intermediate certificate that the browser fetched via AIA butopenssldid not. Fix the server to send the full chain; relying on AIA fetching is fragile and HSTS-incompatible.- A header appears twice → the origin and the CDN both emit it. Browsers honor the first occurrence;
curl -sIshows both lines. Remove one source so the value is unambiguous.
Frequently Asked Questions
Why not just trust securityheaders.com or Mozilla Observatory?
Those graders are an excellent first pass and a good shareable artifact — interpreting their output is covered in header grading with Observatory and securityheaders.com. But they fetch one URL with one request shape and follow redirects their own way. They will not show you the bare 404 page, the OPTIONS preflight, or the /api/ path that ships a different header set. The command line audits the request shapes a grader skips, which is exactly where headers go missing.
Why are the header names lowercase in curl output?
Because the connection is HTTP/2, which mandates lowercased field names on the wire; HTTP/1.1 preserves the casing the server sent. It is not a misconfiguration. Browsers and every compliant parser treat HTTP header field names case-insensitively, so always match with grep -i.
Do I need to install testssl.sh?
No build step. It is a single bash script: git clone https://github.com/testssl/testssl.sh && cd testssl.sh && ./testssl.sh --headers https://example.com, or run the official container with docker run --rm drwetter/testssl.sh --headers https://example.com. It depends only on a reasonably modern OpenSSL, which it bundles for full coverage.
How do I audit a header that is only set after login?
Capture an authenticated session cookie or token and replay it: curl -sI -H 'Cookie: session=<value>' https://example.com/dashboard. Without it you audit the 302 to the login page or the 401 challenge, not the protected resource — and those redirect/error responses frequently lack the headers the protected page carries.
Why check error pages and redirects separately?
Because servers branch on status code. Without the always flag (Nginx, Apache) or its equivalent, security headers are emitted only on successful responses. The 404, 500 and 401 responses — the ones an attacker can most reliably trigger — ship bare, and a redirect hop can drop a header before the final 200. Auditing only the happy-path 200 hides the exact gap an attacker exploits.