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

LLMS index: [llms.txt](/en/llms.txt)

---

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:

| File | Role |
| --- | --- |
| `ca.crt` | root (trust anchor) — this is what you import |
| `server.crt` | service certificate signed by the CA |
| `server.key` | service 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:

```bash
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:

```bash
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:

```bash
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:

```bash
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:

```powershell
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.

| Client | Where your CA has to live |
| --- | --- |
| `curl` / OpenSSL | OS bundle |
| Chrome / Edge on Windows and macOS | OS store (system install is often enough) |
| Chrome / Chromium on Linux | per-user NSS database, not `/etc/ssl/certs` |
| Firefox | per-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).

```bash
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\`

```bash
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:

| OS | Where to put it |
| --- | --- |
| Linux | `/etc/firefox/policies/policies.json` or `distribution/policies.json` in the install dir |
| macOS | `Firefox.app/Contents/Resources/distribution/policies.json` |
| Windows | `distribution\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`

```json
{
  "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.

| Stack | What it uses | What to set |
| --- | --- | --- |
| Python (`certifi`, some `requests`/`httpx`) | its own Mozilla bundle | `SSL_CERT_FILE`, `REQUESTS_CA_BUNDLE`, or `truststore` |
| Node.js | its own list | `NODE_EXTRA_CA_CERTS=/path/to/ca.crt` |
| Java | JDK `cacerts` | `keytool -importcert -alias internal-ca -file ca.crt -keystore "$JAVA_HOME/lib/security/cacerts"` |
| Git | often OS OpenSSL/Schannel | OS CA; otherwise `http.sslCAInfo` |

```bash
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.
