Fixing "certificate verify failed": CA Certs and SSL Verification

Fixing "certificate verify failed": CA Certs and SSL Verification

What You'll Learn

  • The real cause behind "certificate verify failed" and "unable to get local issuer certificate"
  • How to triage which of three buckets is at fault with openssl s_client
  • How to fix CA bundles, incomplete chains, and clock skew the right way

Quick triage

The cause is one of three buckets:

  1. Client side: CA bundle is stale or missing → update-ca-certificates
  2. Server side: intermediate cert not sent (incomplete chain) → serve the fullchain
  3. Environment: system clock is wrong, so validity dates fail → sync with timedatectl

The starting point is the openssl s_client Verify return code.

Assumptions

  • OS: Ubuntu / Debian family (translate paths for RHEL family; covered below)
  • A TLS-verifying client (curl / wget / Python / git) is throwing the error

What does "certificate verify failed" mean?

Conclusion: The client could not chain the server certificate up to a trusted root CA. The cert is rarely fake; usually the verification material is incomplete.

In a TLS handshake the client chains the server certificate up to a root CA to verify it. If that chain does not connect, or the validity dates or hostname do not match, verification fails.

The message differs per tool, but the underlying failure is the same.

curl: (60) SSL certificate problem: unable to get local issuer certificate
ssl.SSLCertVerificationError: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable to get local issuer certificate (_ssl.c:1006)

unable to get local issuer certificate means the issuer's certificate cannot be found. It strongly points to an incomplete chain or a missing CA bundle.

How do I triage the cause?

Conclusion: Use openssl s_client to see the real chain and the Verify return code. The number maps uniquely to which bucket is broken.

Before the browser or curl, observe the raw TLS handshake.

openssl s_client -connect example.com:443 -servername example.com

-servername sets SNI (required on virtual-host setups). Check the Verify return code at the end.

return code Meaning Likely cause
0 (ok) Verification passed Client-specific CA bundle (see below)
20 (unable to get local issuer certificate) Issuer not found Missing CA bundle
21 (unable to verify the first certificate) Chain does not connect Server omits the intermediate cert
10 (certificate has expired) Expired Real expiry or clock skew
9 (certificate is not yet valid) Not yet valid Almost always clock skew
19 (self signed certificate in certificate chain) Self-signed Internal CA / proxy

If openssl s_client returns 0 (ok) but only curl or Python fails, the OS CA store is fine and a client-specific bundle (such as Python's certifi, below) is to blame. This is the key branch point.

You can also dump the leaf certificate's details:

openssl s_client -connect example.com:443 -servername example.com -showcerts </dev/null 2>/dev/null \
  | openssl x509 -noout -dates -subject -issuer
notBefore=Apr  1 00:00:00 2026 GMT
notAfter=Jun 30 23:59:59 2026 GMT
subject=CN=example.com
issuer=CN=Example Intermediate CA

The CA store is stale or missing — now what?

Conclusion: Refresh the client CA bundle. On Debian family, install ca-certificates and run update-ca-certificates.

If you see Verify return code: 20 and the server cert is from a real public CA, your local trust store is out of date.

sudo apt update
sudo apt install --reinstall ca-certificates
sudo update-ca-certificates
Updating certificates in /etc/ssl/certs...
3 added, 0 removed; done.

To trust a custom root (such as an internal CA), drop the PEM file (.crt extension) into the right directory and regenerate.

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

Files in /usr/local/share/ca-certificates/ must use the .crt extension and PEM format. A .pem extension or DER format is ignored. Convert DER with openssl x509 -inform der -in ca.der -out ca.crt.

RHEL / CentOS / Fedora family

The directory and command differ.

sudo cp company-root-ca.crt /etc/pki/ca-trust/source/anchors/
sudo update-ca-trust

The server chain is incomplete — how do I fix it?

Conclusion: Verify return code: 21 is a server misconfiguration. The proper fix is to serve the fullchain, including the intermediate cert — not to work around it on the client.

If the Certificate chain in the s_client output lists only the server certificate (no intermediate CA), the server is not sending the intermediate cert. Many browsers cache or fetch the intermediate, which is why "it works in the browser but fails in curl."

The correct fix is to serve the fullchain on the server.

  • Nginx: point ssl_certificate at a fullchain.pem (server cert + intermediate concatenated)
  • Apache: point SSLCertificateFile at the fullchain (or use SSLCertificateChainFile for the intermediate)
  • Let's Encrypt (certbot): use fullchain.pem, not cert.pem

Verify from an external checker (such as SSL Labs) or openssl s_client from another host — not the browser.

The system clock is wrong — could that be it?

Conclusion: If you get certificate has expired / not yet valid but the cert is actually valid, suspect the system clock. Check sync state with timedatectl.

A certificate's validity window (notBefore / notAfter) is verified against the system clock. On containers or just-resumed VMs with a badly skewed clock, a valid certificate can read as expired or not yet valid.

timedatectl
               Local time: Fri 2026-06-05 12:00:00 UTC
           Universal time: Fri 2026-06-05 12:00:00 UTC
          System clock synchronized: yes
                NTP service: active

If you see System clock synchronized: no or an obviously wrong date, fix NTP.

sudo timedatectl set-ntp true

For the full clock-sync workflow, see Fixing server time skew.

Self-signed or expired certificates

Conclusion: For self-signed certs (return code 19/18), add that CA to the trust store explicitly. Limit verification bypass to one-off triage.

Development environments and corporate proxies use self-signed certificates. The proper approach is to add that certificate (or its issuing CA) to the trust store using the update-ca-certificates steps above.

Only when you must skip verification temporarily, and understand the blast radius:

# One-off triage only. Never make it permanent.
curl -v https://internal.example.com   # see the cause first
curl -k https://internal.example.com   # skip verification (risky)

-k (--insecure) and verify=False keep encryption but stop verifying the peer's identity. They stop eavesdropping but not impersonation (MITM). Never use them for production traffic or when sending credentials.

Tool-specific fixes (curl / Python / git)

Conclusion: When the OS CA is fixed but only Python fails, certifi's separate bundle is the cause. Each tool reads a different CA store.

curl / wget

These read the OS CA store. To point at a specific CA temporarily:

curl --cacert /path/to/ca.pem https://example.com
wget --ca-certificate=/path/to/ca.pem https://example.com

Python (requests / urllib)

requests reads its bundled certifi store, not the OS store. This is the classic case where updating the OS CA does not help.

# Show which CA bundle requests is using
python3 -c "import certifi; print(certifi.where())"

To force a specific CA, set environment variables:

export REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt
export SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt

SSL_CERT_FILE is read by Python's standard ssl module (OpenSSL); REQUESTS_CA_BUNDLE is specific to requests. Pointing both at the OS bundle makes Python behave like the system CA.

git

# Set the CA for a single repository
git config http.sslCAInfo /path/to/ca.pem

# Or via environment variable
export GIT_SSL_CAINFO=/path/to/ca.pem

Avoid git config --global http.sslVerify false — it disables verification entirely.

What not to do

Conclusion: Permanently disabling verification, bulk-trusting unknown CAs, or pinning the clock by hand are "just make it work" fixes that turn into incidents later.

Summary and next reading

  • The cause is one of three buckets — client CA / server chain / clock. The openssl s_client Verify return code maps to each uniquely

  • Fix the client with update-ca-certificates, the server by serving the fullchain, and the clock with timedatectl

  • Remember that tools read different stores: Python uses certifi, git uses http.sslCAInfo

  • Verification bypass (-k, etc.) is for one-off triage only — never permanent

  • Diagnosing "Connection refused"

  • DNS resolution troubleshooting

  • Fixing server time skew