Part IV: Trust and HTTPS · Chapter 12

Who do we trust? CAs, trust stores and self-signed certificates

In one sentence

Every client trusts only the root certificates in its own trust store, so a certificate is accepted only if its chain ends at one of them; being self-signed earns nothing.

Who is this for

Level: Practitioner

On this page
  1. The idea
  2. How it really works
  3. At work
  4. Common mistakes
  5. Why it matters to the business
  6. Check yourself
  7. Go deeper

The idea

  • Beginner

A certificate is signed by a CA. That CA’s certificate is signed by another CA, and so on, until you reach a certificate that signed itself: a root certificate. So why trust the root?

Not because it signed itself. Anyone can make a self-signed certificate in one command, with any name they like, including “Very Trustworthy Root CA”. A root is trusted for one reason only: it is already on a list inside your device or your software. That list is called a trust store.

Who decides what goes on the list?

  • Browser and operating-system makers run root programs. Mozilla, Google (for Chrome), Apple and Microsoft each decide which public CAs to include. A CA must pass yearly audits and follow strict rules to stay in.
  • Your organisation’s IT team can add its own roots to company devices, for example for internal websites.
  • You, when you install a root on your own computer.

Here is the catch: there is no single list. Your phone, your laptop, the Java application on a server and the Docker container next to it can each have a different trust store. The same website can be trusted by one of them and rejected by another, and both are behaving correctly.

A self-signed certificate on a server, one that no CA vouched for, is rejected everywhere it has not been installed. The warning does not mean the encryption is weak. It means nobody your device trusts has confirmed that the key belongs to that name, so you could be talking to anyone, including someone in the middle.

How it really works

  • Developer
  • Expert

What is a trust anchor?

Path validation in RFC 5280 section 6.1 starts from a trust anchor: a CA name and public key that the client accepts without further proof. Trust anchors are usually distributed as self-signed root certificates, but the self-signature adds no trust. It shows only that the file is intact and was made by whoever holds the root’s private key.

Everything else follows from the anchor. A client accepts a server certificate only if it can build a chain of valid signatures from that certificate up to one of its anchors (chapter 13).

Who decides which roots are public?

Four root programs cover almost every device:

Each program requires the CA to follow the CA/Browser Forum’s Baseline Requirements, to pass independent audits, and to report incidents. When a CA breaks the rules badly, the programs remove it (chapter 18).

Which trust store does my software use?

This is where most “it works on my machine” problems come from:

Client Default trust store How to add a private root
Chrome Chrome Root Store, plus roots that users or administrators added to the operating system Add it to the OS store (Windows, macOS)
Firefox Mozilla’s built-in list; on Windows, macOS and Android it also reads roots added to the OS by default OS store, or Firefox settings or policy
Windows apps (.NET, PowerShell, IIS, curl with Schannel) Windows certificate store (Trusted Root Certification Authorities) certutil -addstore, Group Policy, Intune
macOS and iOS apps Apple’s trust store and the keychains security add-trusted-cert, MDM profile
Linux tools (curl, wget, git) built with OpenSSL The distribution’s CA bundle file update-ca-certificates or update-ca-trust
Java cacerts in $JAVA_HOME/lib/security, shipped with the JDK keytool -importcert -cacerts
Node.js A snapshot of Mozilla’s store, built into Node and “fixed at release time” NODE_EXTRA_CA_CERTS, or --use-system-ca
Python ssl module OpenSSL’s default paths; on Windows also the system’s CA and ROOT stores SSL_CERT_FILE, or cafile= in code
Python requests The certifi package, a copy of Mozilla’s list REQUESTS_CA_BUNDLE, or verify= with a path
Go The system’s roots; on Linux, the usual bundle files SSL_CERT_FILE, SSL_CERT_DIR

Three details catch people out:

  • Node.js reads NODE_EXTRA_CA_CERTS “only when the Node.js process is first launched”, and ignores it completely when code passes its own ca option (Node.js CLI docs). Since Node 23.8, --use-system-ca adds the operating system’s roots.
  • Python requests does not use the operating system’s store. It uses certifi, so a root installed on Windows or in /etc/ssl is invisible to it (Requests docs).
  • Minimal container images often contain no CA bundle at all, so every HTTPS call fails with x509: certificate signed by unknown authority until you install the ca-certificates package.
One server chain ending at a private root, checked by four clients with different trust stores Company server www.corp.example issued by Corp Issuing CA issued by Corp Root CA ✓ Browser: trusted OS store contains Corp Root CA ✗ Node.js app: rejected Node's bundled Mozilla list ✗ Python requests: rejected the certifi bundle ✗ Java app: rejected the JDK's cacerts file Each client needs the root in its own trust store.
One server, one chain, four answers. Each client checks against its own trust store.

What do self-signed errors look like?

There are two cases, with different messages:

When the root is not sent at all, the same root cause shows up as “unable to get local issuer certificate” instead. In every case, the fix is to make the client trust the right root, not to switch checking off.

Why do private roots exist?

Organisations run their own CAs for internal names, devices and staff (chapter 22). A private root works exactly like a public one, but only inside trust stores where someone installed it. That is the point: an internal CA should not be able to issue certificates that the whole internet trusts. The cost is distribution. Every device, runtime and container that talks to internal services needs that root.

What is a TLS-inspection proxy?

Many companies route web traffic through a proxy that decrypts and inspects it. The proxy holds a private root that is installed on every company laptop. When you open https://example.com, the proxy connects to the real site, checks its certificate, then gives your laptop a new certificate for example.com, issued on the spot by the proxy’s root. Your browser accepts it, because the OS store contains that root.

Tools with their own trust store do not. Node.js, Python requests, Java, git and containers on the same laptop fail with “unable to get local issuer certificate” or “self-signed certificate in certificate chain”, while the browser works. The fix is to add the company root to those tools’ stores. Keep two facts in mind: anyone who controls a root in your store can read your TLS traffic, so install only roots your organisation controls; and the proxy now makes the trust decisions for you. Chrome, for example, honours locally added roots for TLS over TCP but not for QUIC, the transport of HTTP/3 (Chrome Root Store FAQ).

At work

  • Developer
  • IT ops

Which store is my client using?

Ask each runtime. These commands ran on a Windows laptop in Git Bash; on Linux the paths differ:

openssl version -d
python -c "import ssl; print(ssl.get_default_verify_paths())"
python -c "import certifi; print(certifi.where())"
node -p "require('tls').rootCertificates.length"
OPENSSLDIR: "/mingw64/etc/ssl"
DefaultVerifyPaths(cafile=None, capath=None, openssl_cafile_env='SSL_CERT_FILE', openssl_cafile='C:\\Program Files\\Common Files\\SSL/cert.pem', openssl_capath_env='SSL_CERT_DIR', openssl_capath='C:\\Program Files\\Common Files\\SSL/certs')
C:\Users\…\Python313\Lib\site-packages\certifi\cacert.pem
150

Four answers, four different places: OpenSSL’s own folder, Python’s OpenSSL defaults (plus the Windows store, which ssl loads on Windows), certifi’s bundle for requests, and 150 roots compiled into this Node.js 23 binary. For Java, keytool -list -cacerts lists the JDK’s roots.

What happens with a private root?

We ran a test server whose chain ends in a private root, “Keystone Demo Root CA”, and sent the whole chain, root included. Nothing on the laptop trusts that root yet:

openssl s_client -connect 127.0.0.1:8443 -servername www.example.com </dev/null
depth=2 C=BE, O=Keystone Demo, CN=Keystone Demo Root CA
verify error:num=19:self-signed certificate in certificate chain
...
Verify return code: 19 (self-signed certificate in certificate chain)

Node.js and Python give the same verdict in their own words:

SELF_SIGNED_CERT_IN_CHAIN: self-signed certificate in certificate chain
ssl.SSLCertVerificationError: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: self-signed certificate in certificate chain (_ssl.c:1028)

A server that presents only a self-signed certificate, with no chain, fails slightly differently:

verify error:num=18:self-signed certificate
DEPTH_ZERO_SELF_SIGNED_CERT: self-signed certificate; if the root CA is installed locally, try running Node.js with --use-system-ca

Now give Node.js the root, and only the root, as an extra trusted certificate:

NODE_EXTRA_CA_CERTS=root.crt node -e "const s=require('tls').connect({host:'127.0.0.1',port:8443,servername:'www.example.com'},()=>{console.log('OK, authorized:',s.authorized);s.end()});s.on('error',e=>console.log(e.code+':',e.message))"
OK, authorized: true

Verification is still fully on. The client now simply knows one more anchor.

How do I add a private root?

First, check that the file is the root you expect. Compare its fingerprint with the value your IT team publishes through a channel you trust, as the keytool documentation also advises:

openssl x509 -in corp-root.crt -noout -subject -fingerprint -sha256

Then add it to every store your clients use. The file must be the PEM root certificate, never a private key:

# Debian and Ubuntu: the file name must end in .crt
sudo cp corp-root.crt /usr/local/share/ca-certificates/corp-root.crt
sudo update-ca-certificates

# Red Hat, Fedora and similar
sudo cp corp-root.crt /etc/pki/ca-trust/source/anchors/
sudo update-ca-trust

# macOS
sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain corp-root.crt

# Java (JDK 17 and older, and Debian/Ubuntu packages, ask for the cacerts password "changeit"; newer JDK builds such as Temurin 18+ have none)
keytool -importcert -cacerts -alias corp-root -file corp-root.crt

# Node.js, for processes started from this shell
export NODE_EXTRA_CA_CERTS=/path/to/corp-root.crt

# Python requests: point it at a bundle that contains the root
export REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt
# Windows, as administrator: the computer's Trusted Root store
Import-Certificate -FilePath .\corp-root.crt -CertStoreLocation Cert:\LocalMachine\Root

On managed fleets, distribute roots with Group Policy, Intune or an MDM profile rather than by hand, and remember to bake them into container images.

Which errors point here?

An untrusted or missing root produces most of the classic messages:

If the root is trusted but the error persists, the server is probably not sending its intermediate. That is the next chapter.

Try it: Chain checkerOpen the full tool

Runs in your browser. Nothing you paste is sent anywhere.

Every certificate the server sends, leaf first. The output of openssl s_client -showcerts -connect example.com:443 works as it is: we ignore the text around the certificates.

Paste the chain your server sends, from openssl s_client -showcerts. The checker shows where the chain ends and whether that root is publicly trusted.

Common mistakes

  • “A root is trusted because it is self-signed.” A root is trusted only because it is already in the trust store. Anyone can make a self-signed certificate.
  • “Self-signed means weak encryption.” The encryption can be just as strong. Self-signed means that nobody your device trusts has vouched for the name.
  • “Every device trusts the same roots.” Browsers, operating systems, Java, Node.js, Python and containers each have their own stores, with different contents and update cycles.
  • “I installed the root in Windows, so all my tools trust it.” Node.js, Python requests, Java and many containers ignore the Windows store unless you configure them.
  • “-k is fine for internal services.” Internal networks have attackers too, and the setting tends to reach production. Trust the private root instead.
  • “A private root only affects our internal sites.” A root in your store can vouch for any name, including your bank’s. Install only roots you control, and prefer CAs with name constraints (chapter 22).

Why it matters to the business

  • Leader

Check yourself

Check yourself

Why does your laptop trust a public root CA certificate?

  1. Because the root certificate is self-signed
  2. Because it ships in your operating system's or browser's trust store
  3. Because its key is longer than other keys
Show answer

Answer B Because it ships in your operating system's or browser's trust store

Check yourself

Your company inspects TLS with a proxy. Chrome works, but npm install fails with UNABLE_TO_GET_ISSUER_CERT_LOCALLY. What is the best fix?

  1. Turn off strict SSL checking in npm
  2. Add the company's root certificate to Node's trust, for example with NODE_EXTRA_CA_CERTS
  3. Reinstall Chrome
Show answer

Answer B Add the company's root certificate to Node's trust, for example with NODE_EXTRA_CA_CERTS

Check yourself

A developer adds verify=False to fix CERTIFICATE_VERIFY_FAILED when calling an internal API. What is the risk?

  1. None, because the traffic is still encrypted
  2. Anyone who can intercept the traffic can pose as the API and read or change the data
  3. The API will refuse the connection
Show answer

Answer B Anyone who can intercept the traffic can pose as the API and read or change the data

Go deeper

In the gameLevel 12: Who do we trust?Registry of Roots, Keystone. In development

The crash course covers trust stores in lesson 6, Chains and trust stores.

Early edition. Checked against the standards; an independent expert review is in progress. Found a mistake? Tell us.

Last reviewed Edition 0.9