(message "Blog")

TLS Certificate Validation on Linux

·8 min read

We all know the drill. You type https:// into the browser, a padlock shows up, and everyone collectively agrees not to think about what just happened. That works fine right up until the moment some application on your server starts throwing certificate verify failed while curl against the same endpoint works perfectly. Or the other way around. That’s usually the point where people discover that “the system trusts the certificate” is a much less well-defined statement than it sounds.

So let’s unpack what actually happens when a program on a Linux system decides whether to trust a TLS certificate, and why two programs on the same machine can happily disagree about it.

What the server actually sends

During the TLS handshake, the server doesn’t just send its certificate. It sends (or at least it should send) a whole chain:

  • the leaf certificate, issued for the actual hostname
  • one or more intermediate certificates, which issued the leaf

What it does not send is the root certificate. There would be no point. A root the client doesn’t already have is worthless, since the entire trust model rests on the client having its own local copy of the roots it considers trustworthy. A root certificate arriving over the network from the very party you’re trying to verify would be about as convincing as an email signature saying “definitely not a scammer”.

The client’s job is then to build a path from the leaf it received, through the intermediates, up to a root it already knows and trusts. Each certificate in the chain is signed by the next one up, so the client verifies signatures link by link until it lands on something from its local trust store. If it can’t complete that path (missing intermediate, unknown root, whatever), the connection is refused.

That “missing intermediate” case deserves a special mention because it’s a classic. A misconfigured server that sends only the leaf will work fine in browsers (they cache intermediates seen elsewhere and can even fetch them on demand) and fail miserably with OpenSSL-based tools, which do no such fetching. If you’ve ever had a site that “works in Chrome but not in curl”, there’s a very good chance this was it.

Chain building is only part of the story though. Along the way the client also checks:

  • validity dates - is the certificate expired, or not yet valid
  • hostname - does the name you’re connecting to appear in the certificate’s Subject Alternative Names
  • key usage constraints - is the intermediate actually allowed to issue certificates
  • revocation - in theory via CRL or OCSP, in practice this one gets skipped or soft-failed far more often than anyone in the industry likes to admit

All of that is mechanical. The interesting question is the last step: what exactly is that “local trust store” the chain has to terminate in?

The system trust store

On Linux, root certificates are just files on disk. There’s no TPM magic, no registry, nothing exotic. Just a directory full of PEM-encoded certificates and a couple of symlink/concatenation tricks on top.

The certificates themselves typically come from the distribution’s ca-certificates package, which in turn repackages the Mozilla root program’s list. So when people say “the system trusts this CA”, what they really mean is “Mozilla trusts this CA, Debian repackaged that decision, and my system installed it”. A fun chain of custody to think about, but it works remarkably well.

Where things get less uniform is where those files live and how you add your own. Every distribution family has its own idea:

  • Debian-based systems keep the bundle in /etc/ssl/certs/ca-certificates.crt, and you add local CAs by dropping them into /usr/local/share/ca-certificates/ and running update-ca-certificates
  • Red Hat-based systems use /etc/pki/ca-trust/, you drop files into /etc/pki/ca-trust/source/anchors/ and run update-ca-trust
  • and then there’s everyone else, each with a slightly different path and update command

The update command’s job is to take all the sources (the distro bundle plus your local additions) and regenerate the various formats applications expect: a single concatenated PEM bundle, a hashed directory for OpenSSL’s lookup, sometimes a Java keystore too. Modern distributions increasingly funnel all of this through p11-kit, which acts as a central broker so that different crypto libraries can share one trust source. Increasingly. Not universally. Remember that word, it’s about to matter.

Here’s where it falls apart

The dirty secret is that “the system trust store” is a gentleman’s agreement, not an enforcement mechanism. Nothing in the kernel or anywhere else forces an application to use it. TLS validation happens entirely in userspace, inside whatever TLS library the application happens to link against, and that library reads whatever trust source it was configured to read.

And applications, as it turns out, love bringing their own.

The TLS library landscape alone already fragments things. OpenSSL, GnuTLS, and NSS each have their own default lookup paths, which distros patch to point at the system store with varying degrees of consistency. But the real fun starts one layer up:

  • Java ships its own keystore (cacerts), managed with its own tool (keytool), in its own format, completely oblivious to anything in /etc/ssl. Some distributions wire it up to the system store, some don’t, and the JVM you downloaded as a tarball certainly doesn’t
  • Node.js compiles Mozilla’s root list into the binary. The certificates your Node application trusts were decided when that Node version was built
  • Python - the standard library uses OpenSSL’s defaults, which usually means the system store. But the popular requests library pulls in certifi, which is Mozilla’s bundle shipped as a pip package. So a single Python process can contain two different trust stores depending on which import a given code path uses
  • Browsers famously do their own thing entirely, Firefox with NSS and its own root program, Chrome nowadays with its own root store as well
  • Go reads the system store, but like nearly everything else it honors SSL_CERT_FILE and SSL_CERT_DIR overrides, as do OpenSSL-based tools, joined by app-specific variables like NODE_EXTRA_CA_CERTS and REQUESTS_CA_BUNDLE

From the application vendor’s perspective this is all perfectly rational. Shipping your own trust store means consistent behavior across every platform, no dependency on the distro getting things right, no surprises from whatever the local admin did to /etc/ssl. Java behaves the same on Linux, macOS, FreeBSD, and that one Windows machine nobody wants to touch, precisely because it ignores all of them equally.

From the sysadmin’s perspective, it means the question “does this machine trust my CA?” has no single answer. The machine doesn’t trust anything. Applications do, each in their own way.

The private CA obstacle course

Where this bites hardest is internal infrastructure. You run your own CA (for internal services, a MitM-ing corporate proxy, a homelab, doesn’t matter) and now you need your systems to trust it. The naive expectation is one step: add the CA to the system store, run the update command, done.

The actual experience tends to be more of a scavenger hunt:

  1. add the CA to the system store - curl, wget and most C-linked things are now happy
  2. discover the Java service still fails, keytool -importcert into its cacerts
  3. discover the Node service still fails, set NODE_EXTRA_CA_CERTS
  4. discover the Python service still fails because certifi, set REQUESTS_CA_BUNDLE
  5. discover some container image fails because it has its own /etc/ssl inside, completely separate from the host’s
  6. repeat for every runtime, tool, and container you’ll ever deploy

That last point about containers is worth an extra sentence: an application in a container brings its own filesystem, which means its own trust store, which means all of the above nested one level deeper. Your host trusts the CA just fine, but the process inside the container is validating against the /etc/ssl that came with the image, blissfully unaware of anything you did on the host. The usual fix is to mount the host’s CA bundle over the container’s copy, either as a read-only bind mount or, in Kubernetes, as a volume pointed at the right path.

None of this is broken, strictly speaking. Every one of those applications is behaving exactly as designed. It’s just that “as designed” was decided by a dozen different projects with a dozen different philosophies, and you get to integrate the result.

There’s also a legitimate flip side: an application controlling its own trust chain isn’t only an annoyance, it can be a feature. Pinning, meaning trusting only your specific CA, or even one specific certificate, instead of the two-hundred-ish public roots, dramatically shrinks the attack surface for that one connection. A backend service that talks exclusively to your internal API has no business trusting every certificate authority on the planet. The same mechanism that makes private CAs annoying to roll out is what makes this kind of hardening possible at all.

The trust model itself is fine. Chain building, signatures, local roots, all of it is well-designed and battle-tested. What Linux doesn’t have is a single enforcement point for it, and honestly, it never claimed to. “The system trust store” is a default, a convention that most things follow and nothing has to.

So the next time an application refuses a certificate that every other tool on the box accepts, skip the stage of doubting your sanity and go straight to the useful question: which trust store is this thing actually reading?