A few days ago someone left a comment on my last post about grading security headers automatically:
"Security-header grading is most useful when it explains tradeoffs, not only pass/fail. CSP especially needs context because a stricter policy can be correct technically and still break the product if rollout is blind."
Fair hit. My original grader read Content-Security-Policy and Content-Security-Policy-Report-Only as the same thing — a strict policy still in report-only mode (i.e., not actually blocking anything yet) scored identically to one fully enforced. That's not a rounding error, it's a real blind spot: a team mid-rollout with a report-only CSP gets the same green checkmark as a team that shipped a broken policy and never noticed.
Fixing that made me look harder at what grading security from headers alone actually misses — and the biggest gap isn't in the headers at all. It's in the TLS layer underneath them.
Headers tell you intent. TLS tells you reality.
Strict-Transport-Security says "browsers should only ever load me over HTTPS." It says nothing about whether the certificate serving that HTTPS connection is valid right now, how many days until it expires, which TLS version actually got negotiated (a server can advertise HSTS and still fall back to TLS 1.0 for older clients), or who issued it. A site can have a perfect header score and be running on a cert that expires in 6 days. Headers audit configuration; a live handshake audits ground truth.
Doing the handshake for real
import ssl, socket
from datetime import datetime
def get_cert_info(hostname: str, port: int = 443, timeout: float = 3.0):
ctx = ssl.create_default_context()
with socket.create_connection((hostname, port), timeout=timeout) as sock:
with ctx.wrap_socket(sock, server_hostname=hostname) as ssock:
cert = ssock.getpeercert()
version = ssock.version()
not_after = datetime.strptime(cert["notAfter"], "%b %d %H:%M:%S %Y %Z")
return {
"issuer": dict(x[0] for x in cert["issuer"]),
"valid_until": not_after.isoformat(),
"tls_version": version,
"is_expired": datetime.utcnow() > not_after,
}
Three things matter beyond the happy path:
-
It's blocking I/O. In an async service, wrap it:
await loop.run_in_executor(None, get_cert_info, hostname). -
It has to fail soft. Self-signed certs, non-TLS ports, and slow/unreachable hosts are all normal inputs here, not exceptions to crash on — catch broadly and return
None, don't let a cert-inspection add-on take down a response that would otherwise be fine. - It's a second round-trip you didn't need before. If your header audit only reads response headers you already have in memory, bolting on a live handshake by default silently doubles your latency for every caller, most of whom never asked for it.
Make it opt-in, not default
That third point is why I didn't fold this into the existing security-audit endpoint's default behavior — I gated it behind a query flag (include_tls_details=true) that's off unless explicitly requested. The base audit stays header-only and fast; anyone who wants the deeper cert check asks for it and pays the extra round-trip knowingly.
I ended up shipping both fixes — Report-Only-aware header context and opt-in TLS inspection — in the API from my last post:
GitHub: https://github.com/JosejuX/rapidapi-metadata-extractor
Try it: https://rapidapi.com/josejuanjocoding/api/web-metadata-and-contact-extractor
Thanks again to the commenter who pushed on this — pass/fail without the why is exactly the kind of audit tool that trains people to chase a green checkmark instead of understanding the header.
Top comments (0)