Skip to content

TLS Certificate Verification — What “Chain of Trust” Actually Means

TLS Certificate Verification — What “Chain of Trust” Actually Means

When a browser’s address bar shows a lock icon, what is actually being guaranteed? “The connection is encrypted” is one part of it, but not the whole story. The other part is: “this domain’s certificate can be verified by following a chain of trusted third parties back to something already trusted.” That chain is what’s called the chain of trust. This post walks through how that verification works, then looks at two places in our own tool’s code where we handle it in opposite ways — one strict, one deliberately relaxed.

A certificate only means something once someone else has signed it

On its own, a TLS certificate is just a self-declared claim — “I am this domain.” What gives that claim weight is a digital signature attached to it. A server certificate (the “leaf” certificate) is normally signed by an intermediate certificate authority (CA), and that intermediate CA’s own certificate is in turn signed by a root CA above it. Root certificates are the one exception — nobody signs them; they’re self-signed — but in exchange, they come pre-installed in the operating system or browser as things already trusted in advance (the “trust store”).

A verifier — a browser or an HTTP client — takes the presented leaf certificate and walks the signature chain upward: leaf to intermediate, intermediate to root. If it can trace that chain all the way to a root certificate already sitting in its trust store, it considers the certificate trustworthy. If even one signature along the way fails to verify, the chain breaks there, and the certificate is not trusted.

“The certificate is valid” and “it’s for this hostname” are separate checks

It’s easy to conflate chain-of-trust verification with another, separate check that happens alongside it: does this certificate actually belong to the hostname being connected to? Even a certificate that’s correctly signed by a legitimate CA is meaningless as proof of identity if the domain name recorded on it (the Common Name / Subject Alternative Name) doesn’t match the hostname you’re actually talking to — that would just be someone else’s perfectly valid certificate, presented in the wrong place, which is exactly what an impersonation attack looks like. TLS verification is really two independent checks working together: is the signature chain trustworthy, and does the hostname match.

How this compares to code signing’s chain of trust

We covered Apple Notarization and Windows Authenticode in an earlier post on code signing basics, and it turns out that system shares the exact same skeleton. Windows Authenticode’s certificate chain also walks from a publisher’s certificate up to a root certificate to establish trust — structurally identical to TLS. What differs is the contents of the trust store: TLS consults a list of root CAs that browsers and operating systems maintain specifically for websites, while code signing consults a separate list that Apple or Microsoft maintains specifically for software publishers. The chain mechanism is the same; what differs is which trust store it was designed to serve.

Example 1: strict verification — license and update checks

Our tool’s core/license.py and core/updater.py both talk to our own license-check and update-check servers over HTTPS, and both explicitly point their SSL context at the CA bundle bundled with the certifi package:

def _ssl_context() -> ssl.SSLContext:
    try:
        import certifi
        return ssl.create_default_context(cafile=certifi.where())
    except ImportError:
        return ssl.create_default_context()

There’s a concrete reason for spelling out certifi explicitly. A macOS build packaged with PyInstaller can’t reach the OS’s system keychain (its native trust store), and without an explicit CA bundle, the connection simply fails with CERTIFICATE_VERIFY_FAILED. License checks and update checks talk to servers we control ourselves, and if an attacker could impersonate either one, the result could be a forged license response or a malicious update payload. That’s exactly the scenario chain-of-trust verification exists to prevent, so here it’s deliberately left fully intact rather than worked around.

Example 2: deliberately skipped verification — health-checking client sites

maintenance_agent.py::_http_status_check() does the opposite:

_ctx = ssl.create_default_context()
_ctx.check_hostname = False
_ctx.verify_mode = ssl.CERT_NONE

Both the hostname check and certificate verification are turned off on purpose. This function’s job is to confirm that a site is returning a normal HTTP status code before and after a maintenance run — and the sites it checks live on whatever hosting each client happens to be using, where expired certificates, self-signed certificates, or staging-environment certificate mismatches are all reasonably common. Applying strict TLS verification here would create a worse failure mode than the one it’s trying to avoid: a site working perfectly fine getting misjudged as broken (and its maintenance run incorrectly rolled back) purely because of an unrelated certificate quirk. What this function actually needs to know isn’t “who is this server,” it’s just “did a status code come back” — and for that purpose, chain-of-trust verification isn’t protecting anything worth protecting.

Takeaway

The chain of trust behind TLS certificates is a straightforward mechanism — walk a chain of signatures up to a root certificate already trusted — but treating “always verify strictly” as a universal rule misses the point. Whether verification is worth doing depends on what you’re actually trying to protect. For an endpoint you control and where impersonation would be genuinely damaging — a license server, an update server — chain-of-trust verification needs to work reliably, no shortcuts. For a simple health check against an arbitrary third-party site, verifying that site’s identity may not even be relevant to the question being asked. Whether to verify at all turns out to be a design decision, the same way code-signing choices are — and once you see it that way, it’s easier to understand why one piece of code enforces certificate checks strictly while another deliberately relaxes them.