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
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:
- the Mozilla Root Store Policy, used by Firefox and, through the NSS library, by many Linux distributions;
- the Chrome Root Program, whose root store Chrome uses on Windows, macOS, ChromeOS, Linux and Android (Apple’s rules prevent it on iOS);
- the Apple Root Certificate Program, for macOS, iOS and Safari;
- the Microsoft Trusted Root Program, for Windows.
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 owncaoption (Node.js CLI docs). Since Node 23.8,--use-system-caadds the operating system’s roots. - Python
requestsdoes not use the operating system’s store. It usescertifi, so a root installed on Windows or in/etc/sslis 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 authorityuntil you install theca-certificatespackage.
What do self-signed errors look like?
There are two cases, with different messages:
- The server sends only a self-signed certificate. OpenSSL reports
verify error:num=18:self-signed certificate, and Node.js reportsDEPTH_ZERO_SELF_SIGNED_CERT. - The server sends a chain that ends in a root the client does not trust. OpenSSL reports
verify error:num=19:self-signed certificate in certificate chain, curl saysSSL certificate problem: self-signed certificate in certificate chain, and Node.js saysSELF_SIGNED_CERT_IN_CHAIN.
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:
- browsers:
NET::ERR_CERT_AUTHORITY_INVALIDandSEC_ERROR_UNKNOWN_ISSUER; - tools: curl’s self-signed chain error,
SELF_SIGNED_CERT_IN_CHAIN, npm’sUNABLE_TO_GET_ISSUER_CERT_LOCALLYand Go’sx509: certificate signed by unknown authority; - runtimes: Java’s
PKIX path building failed, Python’sCERTIFICATE_VERIFY_FAILEDand .NET’sThe remote certificate is invalid.
If the root is trusted but the error persists, the server is probably not sending its intermediate. That is the next chapter.
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. - “
-kis 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
Why does your laptop trust a public root CA certificate?
RightNot quite The answer is B: Because it ships in your operating system's or browser's trust store
- What happened
- It is trusted because it is in the trust store.
- Why
- A self-signature proves nothing about trust: anyone can make one. Root programs decide which roots ship in the store.
- The rule
- A root is trusted only because it is already in the trust store.
Show answer
Answer B Because it ships in your operating system's or browser's trust store
- What happened
- It is trusted because it is in the trust store.
- Why
- A self-signature proves nothing about trust: anyone can make one. Root programs decide which roots ship in the store.
- The rule
- A root is trusted only because it is already in the trust store.
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?
RightNot quite The answer is B: Add the company's root certificate to Node's trust, for example with NODE_EXTRA_CA_CERTS
- What happened
- Give Node.js the company root, so it can verify the proxy's certificates.
- Why
- Chrome trusts the root through the operating system store. Node.js uses its own bundled list, which lacks the company root.
- The rule
- Fix trust errors by adding the right root to the client's store, never by turning verification off.
Show answer
Answer B Add the company's root certificate to Node's trust, for example with NODE_EXTRA_CA_CERTS
- What happened
- Give Node.js the company root, so it can verify the proxy's certificates.
- Why
- Chrome trusts the root through the operating system store. Node.js uses its own bundled list, which lacks the company root.
- The rule
- Fix trust errors by adding the right root to the client's store, never by turning verification off.
A developer adds verify=False to fix CERTIFICATE_VERIFY_FAILED when calling an internal API. What is the risk?
RightNot quite The answer is B: Anyone who can intercept the traffic can pose as the API and read or change the data
- What happened
- The client now accepts any certificate, including an attacker's.
- Why
- Encryption without verification protects you only from passive listeners. A man-in-the-middle simply presents its own certificate.
- The rule
- Keep verification on and trust the right root instead.
Show answer
Answer B Anyone who can intercept the traffic can pose as the API and read or change the data
- What happened
- The client now accepts any certificate, including an attacker's.
- Why
- Encryption without verification protects you only from passive listeners. A man-in-the-middle simply presents its own certificate.
- The rule
- Keep verification on and trust the right root instead.
Go deeper
- RFC 5280, section 6.1: path validation and the role of trust anchors.
- Mozilla Root Store Policy and Chrome Root Program: the rules public CAs must follow to be included.
- Chrome Root Store FAQ: where Chrome’s roots come from and how locally added roots are treated.
- Apple Root Certificate Program and Microsoft Trusted Root Program: the other two major root programs.
- Node.js:
NODE_EXTRA_CA_CERTSand--use-system-ca: adding roots to Node.js. - Python
ssland Requests: CA certificates: where Python finds its roots. - Java
keytool: thecacertskeystore and importing trusted certificates. - curl:
--cacertand--insecure: giving curl a CA bundle, and why skipping verification is unsafe.
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.