CertMon

SECURITY & CERTIFICATES

Fix "unable to verify the first certificate" in Node, curl and Python with a local CA on macOS

Safari and Chrome show a padlock for https://myapp.test, but Node throws Error: unable to verify the first certificate (UNABLE_TO_VERIFY_LEAF_SIGNATURE, or SELF_SIGNED_CERT_IN_CHAIN), curl prints curl: (60) SSL certificate problem: unable to get local issuer certificate, and Python requests raises SSLError: CERTIFICATE_VERIFY_FAILED. These tools do not read the macOS keychain. Hand each one your root CA file.

Start in Terminal: get the root certificate as a file

You need the CA's public certificate in PEM format (the text file that starts with -----BEGIN CERTIFICATE-----). Never the CA's private key.

# mkcert: prints the folder that holds rootCA.pem
mkcert -CAROOT

# Any CA trusted in your login keychain, by its name
security find-certificate -c "My Local CA" -p > ~/rootCA.pem

In CertMon, open the Trust & Devices tab and click Export CA Certificate (.crt). The file it saves is PEM, so it works wherever rootCA.pem appears below.

The fix for each tool

# Node.js: adds your CA to Node's built-in list
NODE_EXTRA_CA_CERTS="$HOME/rootCA.pem" node app.js

# curl
curl --cacert ~/rootCA.pem https://myapp.test
export CURL_CA_BUNDLE="$HOME/rootCA.pem"

# Python: requests, then the ssl module, urllib and httpx
export REQUESTS_CA_BUNDLE="$HOME/rootCA.pem"
export SSL_CERT_FILE="$HOME/rootCA.pem"

Java: import the CA into a truststore

# Copy the JDK truststore, add your CA to the copy, point the JVM at it
cp "$JAVA_HOME/lib/security/cacerts" ~/dev-cacerts && chmod u+w ~/dev-cacerts
keytool -importcert -noprompt -alias local-dev-ca -file ~/rootCA.pem \
  -keystore ~/dev-cacerts -storepass changeit
java -Djavax.net.ssl.trustStore="$HOME/dev-cacerts" \
  -Djavax.net.ssl.trustStorePassword=changeit -jar app.jar

Docker containers

A container has its own trust store and never sees your keychain. On Debian, Ubuntu and Alpine images (with the ca-certificates package installed), the file name must end in .crt:

COPY rootCA.pem /usr/local/share/ca-certificates/local-dev-ca.crt
RUN update-ca-certificates
# Node inside the container still needs this
ENV NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/local-dev-ca.crt

Why the browser trusts it and your script does not

Trusting a CA in Keychain Access changes the macOS trust store. Safari and Chrome use it. Most developer runtimes do not:

Apple's own /usr/bin/curl is the exception. It is built with LibreSSL and reads /etc/ssl/cert.pem, but it also falls back to the macOS trust evaluation: in our test on macOS 26 it still verified a public site when --cacert pointed at an unrelated CA. So a CA trusted in the keychain is normally accepted by /usr/bin/curl with no flags. If curl fails for you, run which -a curl and curl --version: a Homebrew or Anaconda curl earlier in your PATH is built with OpenSSL and ignores the keychain.

The easier way with CertMon

CertMon does not change how Node or Python pick their CA list, so the variables above still apply. What it takes off your hands is the rest: it creates the root certificate, trusts it in your login keychain, limits it with X.509 Name Constraints to .test and .localhost names, and serves your dev servers at https://name.test through its DNS responder and HTTPS reverse proxy. The constraint limits what the root could be misused for in any client that enforces Name Constraints, which matters once you start copying it into NODE_EXTRA_CA_CERTS and container images.

CertMon dashboard listing four .test sites marked Active and Trusted, with the Trust and Devices tab in the sidebar.
Export the root certificate from the Trust & Devices tab, then point your runtimes at the file.

Download CertMon free trial

Related guides