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:
- Client side: CA bundle is stale or missing →
update-ca-certificates - Server side: intermediate cert not sent (incomplete chain) → serve the fullchain
- 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_clientto see the real chain and theVerify 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-certificatesand runupdate-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: 21is 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_certificateat afullchain.pem(server cert + intermediate concatenated) - Apache: point
SSLCertificateFileat the fullchain (or useSSLCertificateChainFilefor the intermediate) - Let's Encrypt (certbot): use
fullchain.pem, notcert.pem
Verify from an external checker (such as SSL Labs) or openssl s_client from another host — not the browser.
Even when you cannot fix the server, do not disable verification (-k / verify=False) and leave it that way. You lose the ability to detect a man-in-the-middle. Use it only for one-off triage.
The system clock is wrong — could that be it?
Conclusion: If you get
certificate has expired/not yet validbut the cert is actually valid, suspect the system clock. Check sync state withtimedatectl.
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.
Avoid these
- Hard-coding
curl -k/verify=False/sslVerify falseinto config files or scripts - Adding an untrusted CA to the trust store without knowing the cause
- Pinning a skewed clock with
date -sand stopping NTP (recurrence and log inconsistency) - Working around a server chain bug on every client (the fix belongs on one server)