Skip to main content

        Trusting a custom CA: system store, browsers, and CLI - Featured image

Trusting a custom CA: system store, browsers, and CLI

A TLS error that says the certificate is untrusted almost never means the certificate is “broken”. The trust anchor is in the wrong store.

curl, openssl, Git, Python, and the browser are different clients. Some keep their own root lists. Updating the OS bundle on Linux will not fix Chrome or Firefox by itself.

What to import

You trust the CA root that signed the server certificate, not localhost.crt / app.example.internal itself.

Typical files:

FileRole
ca.crtroot (trust anchor) — this is what you import
server.crtservice certificate signed by the CA
server.keyservice private key, server-side only

A self-signed server certificate with no separate CA can be added as an anchor on its own. For labs and internal PKI you usually keep a CA: one root, many services.

PEM (-----BEGIN CERTIFICATE-----) or DER both work. Most OS tools expect PEM. Browsers and certutil accept DER as well.

The system store

This is what OpenSSL-based clients use: curl, wget, git, many agents. Do this first, then debug the browser.

Linux

Distros rebuild /etc/ssl/certs in different ways.

Debian, Ubuntu, and derivatives — a .crt file under /usr/local/share/ca-certificates/, then rebuild the bundle:

sudo cp ca.crt /usr/local/share/ca-certificates/internal-ca.crt
sudo update-ca-certificates

RHEL, Fedora, Alma, Rocky — drop the anchor, then extract:

sudo cp ca.crt /etc/pki/ca-trust/source/anchors/internal-ca.crt
sudo update-ca-trust extract

Alpine — package ca-certificates, same update-ca-certificates, directory /usr/local/share/ca-certificates/.

Then:

curl -I https://app.example.internal
openssl s_client -connect app.example.internal:443 -servername app.example.internal </dev/null 2>/dev/null | openssl x509 -noout -issuer -subject

If curl is clean and the browser is not, the OS trust is fine. The browser is looking elsewhere.

macOS

Use the System keychain, not the login one, if every process on the machine should trust the CA:

sudo security add-trusted-cert -d -r trustRoot \
  -k /Library/Keychains/System.keychain ca.crt

For a single user, the login keychain is enough: no sudo, ~/Library/Keychains/login.keychain-db. GUI: Keychain Access → System → Certificates → import → Always Trust for SSL.

Windows

The Local Machine Root store:

certutil -addstore -f Root ca.crt

Same via certmgr.msc (current user) or certlm.msc (computer): Trusted Root Certification Authorities → import.

Note

In a container or CI job the bundle lives inside the image. Installing a CA on the host does not help curl in the container. Copy ca.crt into the image and run the same update-ca-certificates / update-ca-trust at build time.

Why the browser still complains

Chrome takes public roots from the Chrome Root Store. Extra CAs come from the OS — but not on every platform.

ClientWhere your CA has to live
curl / OpenSSLOS bundle
Chrome / Edge on Windows and macOSOS store (system install is often enough)
Chrome / Chromium on Linuxper-user NSS database, not /etc/ssl/certs
Firefoxper-profile store; OS roots only via policy, and not on Linux

“Installed the CA on the system” and “opened the site in a browser” are separate steps.

Chrome, Chromium, Edge

GUI is the same idea on every OS: Settings → Privacy and security → Security → manage certificates. Authorities tab → import ca.crt. Trust for website identification is enough.

Direct URL: chrome://settings/certificates (Edge: edge://settings/certificates).

CLI on Linux. Chromium/Chrome use an NSS Shared DB. Since M146 the default is ~/.local/share/pki/nssdb; if ~/.pki/nssdb already exists, that one wins.

certutil comes from libnss3-tools (Debian family) or nss-tools (RHEL/Fedora/Alpine).

NSSDB="${HOME}/.pki/nssdb"
[ -d "$HOME/.local/share/pki/nssdb" ] && NSSDB="${HOME}/.local/share/pki/nssdb"

mkdir -p "$NSSDB"
certutil -d "sql:${NSSDB}" -N --empty-password 2>/dev/null || true
certutil -d "sql:${NSSDB}" -A -t "C,," -n "Internal CA" -i ./ca.crt
certutil -d "sql:${NSSDB}" -L

The three -t fields are SSL, email, and code signing. C means trusted CA. Need client-certificate issuance as well — "CT,,". A self-signed server cert with no CA — "P,,".

Fully quit the browser and open it again. Background Chrome processes will not pick up the new cert.

On Windows and macOS a separate NSS import is usually unnecessary: the OS store is enough.

Firefox

Its own database per profile. Snap/Flatpak/vendor builds use different paths — importing into a “normal” profile does not reach them.

GUI: Settings → Privacy & Security → Certificates → View Certificates → Authorities → Import. Enable trust for identifying websites.

CLI — same certutil, profile directory, not ~/.pki/nssdb.

Linux: ~/.mozilla/firefox/<id>.default-release/ macOS: ~/Library/Application Support/Firefox/Profiles/<id>.default-release/ Windows: %APPDATA%\Mozilla\Firefox\Profiles\<id>.default-release\

PROFILE=$(find ~/.mozilla/firefox -maxdepth 1 -type d -name '*.default-release' | head -n 1)
certutil -d "$PROFILE" -A -t "C,," -n "Internal CA" -i ./ca.crt

Substitute the right profile path if you have more than one.

Policy: one file for every profile

For a fleet, policies.json beats hand imports.

Policy location:

OSWhere to put it
Linux/etc/firefox/policies/policies.json or distribution/policies.json in the install dir
macOSFirefox.app/Contents/Resources/distribution/policies.json
Windowsdistribution\policies.json next to firefox.exe, or GPO

ImportEnterpriseRoots makes Firefox trust roots from the OS store. It works on Windows and macOS. Mozilla does not implement it on Linux: there is no OS certificate store in the same sense.

On Linux (and as an explicit list on any OS) use Certificates.Install: a filename or an absolute path. A bare filename is searched here:

  • Linux: /usr/lib/mozilla/certificates, /usr/lib64/mozilla/certificates, ~/.mozilla/certificates
  • macOS: /Library/Application Support/Mozilla/Certificates, ~/Library/Application Support/Mozilla/Certificates
  • Windows: %LOCALAPPDATA%\Mozilla\Certificates, %APPDATA%\Mozilla\Certificates
{
  "policies": {
    "Certificates": {
      "Install": ["internal-ca.crt"]
    }
  }
}

PEM and DER both work. Restart Firefox after changing the policy.

security.enterprise_roots.enabled in about:config is the same as ImportEnterpriseRoots: Windows and macOS, not Linux. On Linux, Certificates.Install or the p11-kit-trust PKCS#11 module is the equivalent.

CLIs and runtimes that still ignore the OS

Even after the system CA is in place, parts of the stack ship their own bundle.

StackWhat it usesWhat to set
Python (certifi, some requests/httpx)its own Mozilla bundleSSL_CERT_FILE, REQUESTS_CA_BUNDLE, or truststore
Node.jsits own listNODE_EXTRA_CA_CERTS=/path/to/ca.crt
JavaJDK cacertskeytool -importcert -alias internal-ca -file ca.crt -keystore "$JAVA_HOME/lib/security/cacerts"
Gitoften OS OpenSSL/SchannelOS CA; otherwise http.sslCAInfo
export SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt
export NODE_EXTRA_CA_CERTS=/etc/ssl/certs/internal-ca.pem

macOS and Windows use different paths for the system bundle; for Node it is simpler to point at ca.crt itself.

Short checklist

  1. Import the CA, not the service leaf.
  2. Put it in the OS store and verify with curl / openssl s_client.
  3. Chrome on Linux needs NSS as well; on Windows/macOS step 2 is often enough.
  4. Firefox needs a profile import or policies.json; ImportEnterpriseRoots is Windows/macOS only.
  5. In a container, a JDK, and Node, check that runtime’s bundle, not only the OS.

There is no single command that covers all of this. There is a predictable order: OS → browser → runtime.