Migrate to RunxBuild and earn up to $50 in hosting credit on your first deposit.

Calculate your savings
unxBuild

SSL certificate_verify_failed: What the Error Means and How to Stop Seeing It

Sean

Platform Writer

Jun 19, 2026
7 min read

SSL: CERTIFICATE_VERIFY_FAILED means the client (your Python script, your requests call, your httpx request) could not build a chain of trust from the server’s certificate back to a trusted root CA. The four causes, in order of frequency: the server is presenting a self-signed certificate, the server is missing an intermediate certificate in the chain, the local certifi CA bundle is outdated, or the system clock is wrong. Every “certificate_verify_failed” report is one of those four. The fix is almost never verify=False (the “skip verification” hack), and the production answer is almost always one of the first three.

The reason this is a top search is that the error is a single line and the cause is a chain of trust. The chain is invisible to the developer, and the error is visible from across the room. The mismatch is what makes the debugging loop painful.

This post is the diagnostic that walks the chain and finds the broken link.

SSL certificate_verify_failed: what the error means and how to stop seeing it

Table of contents

The direct answer: walk the chain

The diagnostic that catches all four causes is openssl s_client, run against the same host and port the Python script is trying to reach:

openssl s_client -connect example.com:443 -servername example.com -showcerts </dev/null

The output is the full chain, from the server’s leaf certificate to the root CA. Read the output in this order:

  1. The first certificate is the server’s leaf. Check the subject (the hostname) and the issuer (who signed it).
  2. The chain certificates (the ones after -----BEGIN CERTIFICATE----- blocks) are the intermediates. The chain is complete when one of them is signed by a CA in your trust store.
  3. The “verify return code” at the end of the output is the answer: 0 means OK, anything else is the error. Common codes:
    • 20: unable to get local issuer certificate — your trust store does not have the root CA.
    • 21: unable to verify the first certificate — the chain is missing an intermediate.
    • 10: certificate has expired — the leaf cert is past its notAfter date.
    • 18: self-signed certificate in chain — the server is presenting a self-signed cert.

If the openssl s_client output shows verify return code: 0, the server is fine and the problem is on the client side (outdated certifi, clock skew, or a proxy that mangles the chain). If the output shows an error code, the server is the problem.

Cause 1: self-signed certificate

The server is using a certificate it generated itself, not one signed by a trusted CA. Common in development, in internal services, and in any setup where the operator clicked “generate a self-signed cert” instead of “get a cert from Let’s Encrypt.”

The diagnostic:

$ openssl s_client -connect internal-api.local:443 -servername internal-api.local </dev/null
...
subject=/CN=internal-api.local
issuer=/CN=internal-api.local
...
verify return code: 18 (self-signed certificate in chain)

The fix depends on the context:

  • For a real production service: get a real certificate. Let’s Encrypt is free; most reverse proxies (Caddy, nginx with the certbot plugin, Traefik) handle the renewal automatically. There is no excuse for a self-signed cert on a customer-facing service in 2026.
  • For an internal service: get a real certificate from your internal CA, or use a tool like mkcert to generate a cert the local machines trust. Both are better than verify=False.
  • For a one-off development test: verify=False is acceptable as a temporary debug step. The right fix is to add the self-signed cert to the local trust store, or to point verify= at the cert file.

Cause 2: missing intermediate certificate

The server has a valid leaf and a valid root, but is not sending the intermediate certificate in the chain. The client has the root (because it’s in the trust store) and has the leaf (because the server sent it), but it cannot build the path because the intermediate is missing.

The diagnostic is the same openssl s_client command. The output will show the leaf certificate, then either nothing or a stub, then verify return code: 21 (unable to verify the first certificate).

The fix is on the server: configure the server to send the full chain, not just the leaf. For nginx, the ssl_certificate directive should point at a file that contains the leaf followed by all intermediates, in order:

-----BEGIN CERTIFICATE-----
(leaf cert)
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
(intermediate 1)
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
(intermediate 2)
-----END CERTIFICATE-----

For Caddy, the chain is usually handled automatically. For Apache, the SSLCertificateChainFile directive points at the intermediate bundle. For a managed PaaS (Render, Fly.io, AWS ALB, Cloudflare), the chain is usually provided by the platform, and the bug is in the custom cert upload.

The interesting variant: the chain works in browsers (which cache intermediates) but fails in requests (which does not cache). The browser has seen the intermediate from a previous visit; requests has not. The server is the same; the experience is different.

Cause 3: outdated certifi bundle

The local Python install is using a certifi bundle that does not have the root CA that signed the server’s certificate. This is more common than people think: a Python install from a year ago has a certifi bundle from a year ago, and the CA that signed the new server’s cert was added to the trust store six months later.

The diagnostic:

$ python3 -c "import certifi; print(certifi.where())"
/home/me/.local/share/uv/python/cpython-3.12.7-linux-x86_64-gnu/lib/python3.12/site-packages/certifi/cacert.pem
$ openssl x509 -in $(python3 -c "import certifi; print(certifi.where())") -noout -subject -issuer -dates

Compare the bundle’s notAfter date against the current date and against the server’s certificate. If the bundle is older than the server’s CA, it may not have the root.

The fix is to update certifi:

python3 -m pip install --upgrade certifi

For a Docker image, the fix is to update the base image, or to add pip install --upgrade certifi to the Dockerfile. The base python:3.12-slim image has a certifi from the image build date, which can be months old.

The trap: many systems (RHEL, CentOS, Amazon Linux) have a system CA bundle at /etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem or /etc/ssl/certs/ca-certificates.crt that is separate from certifi. The requests library uses certifi by default, not the system bundle. The fix is either to use verify='/path/to/system/bundle' or to update certifi.

Cause 4: clock skew

The system clock is more than a few minutes off from the real time. TLS certificates have a notBefore and notAfter date, and the client checks them against its local clock. A clock that is set to 2025 when the cert is valid in 2026 produces a notBefore failure; a clock that is set to 2027 produces a notAfter failure.

The diagnostic:

$ date
$ openssl s_client -connect example.com:443 -servername example.com </dev/null 2>&1 | grep -E 'notBefore|notAfter|verify return'

If the local date is wrong, fix the clock. On Linux, sudo timedatectl set-ntp true and wait a minute. On macOS, System Settings > General > Date & Time > Set automatically. On a Docker container, the fix is to mount /etc/localtime from the host, or to install and run chrony inside the container.

The interesting variant: the host’s clock is right, the container’s clock is wrong, and the Python script in the container fails the verification. The fix is the same — synchronize the container’s clock to the host’s, or to an NTP source.

The Python-specific case: requests, httpx, and urllib

Python’s three common HTTP libraries handle the trust store differently.

requests uses certifi.where() by default. To override:

import requests
r = requests.get("https://internal-api.local", verify="/path/to/ca-bundle.pem")

To debug, set the REQUESTS_CA_BUNDLE environment variable to the path of the bundle you want to use, or to a directory of cert files (/etc/ssl/certs on most Linux systems).

httpx follows the same pattern but can also be configured to use the system trust store via the truststore package. For services running on Python 3.10+ with truststore installed, httpx uses the operating system’s CA bundle, which is the right answer for managed systems.

urllib (the standard library) uses the system trust store via ssl.create_default_context(). The behavior is the most correct of the three, but the API is the worst.

The diagnostic in Python is the same as the diagnostic in openssl s_client: build the chain, find the broken link, fix it on the server (or update the trust store, if the server is right). The Python-specific question is “which trust store is the library using,” and the answer is “probably certifi, possibly the system, never the one you assumed.”

The production answer: do not turn off verification

The internet is full of “fix” snippets that suggest verify=False or ssl._create_default_https_context = ssl._create_unverified_context. These are debug tools. They are not production fixes. They disable the only security check that prevents a man-in-the-middle attacker from impersonating the server.

The right production answer, in order:

  1. Get a real certificate from Let’s Encrypt or your CA. Free, automatic, supported by every modern reverse proxy.
  2. Configure the server to send the full chain. The leaf plus the intermediates, in order.
  3. Update the client’s trust store. pip install --upgrade certifi or use the system bundle.
  4. Fix the clock. NTP, always, on every box that does TLS.
  5. For an internal service, use a private CA. mkcert for development, an internal CA for production.

If none of those work, the right answer is “fix the underlying certificate problem,” not “disable verification.” The certificate problem is a real security bug. Disabling the check is a real security bug. Pick one.

For a hosted service that handles TLS automatically, the deploy path through a managed platform is the way to not have this problem. The platform’s load balancer terminates TLS with a managed certificate, the chain is always correct, and the deploy process does not include “remember to renew the cert in 60 days.” The RunxBuild deploy path is one example; Render, Fly.io, and Vercel all do the same thing.

How this fits the rest of the stack

A managed TLS certificate is also a hosting cost line item — the certificate, the renewal, the monitoring, and the rotation each show up as a piece of operational work. The team’s mental model for the TLS cost is the time the team spends on cert management, which is a real cost even when the certificate itself is free. The RunxBuild hosting calculator is the right place to model that — pick the service count, the domain count, the certificate tier, and the bandwidth, and the calculator shows what the deploy costs when TLS is part of the platform instead of a separate checklist.

Useful related references:

FAQ

What does “SSL: CERTIFICATE_VERIFY_FAILED” mean?

It means the client (your Python script) could not build a chain of trust from the server’s certificate back to a trusted root CA. The four common causes are a self-signed certificate, a missing intermediate, an outdated certifi bundle, or a clock skew.

How do I fix certificate_verify_failed in Python requests?

The first step is to diagnose the cause with openssl s_client -connect host:443 -servername host. The output shows the chain and the verify return code. The fix is on the server side (real cert, full chain) or on the client side (update certifi, fix the clock).

Is verify=False safe to use?

No. It disables the only check that prevents a man-in-the-middle attacker from impersonating the server. Use it only for a temporary debug step, and fix the underlying certificate problem before shipping.

How do I update the certifi CA bundle in Python?

python3 -m pip install --upgrade certifi. For a Docker image, either use a recent base image or add the same pip install to the Dockerfile.

Why does the same URL work in a browser but fail in Python?

The browser caches intermediates from previous visits, and Python’s requests does not. The server is missing an intermediate, the browser is hiding the problem, and requests is exposing it. Fix the server.

How do I use a custom CA bundle in Python requests?

Pass verify="/path/to/ca-bundle.pem" to the request, or set the REQUESTS_CA_BUNDLE environment variable. For a directory of certs, set REQUESTS_CA_BUNDLE to the directory path.

#ssl certificate_verify_failed#python requests ssl error#sslerror#self signed certificate#certifi bundle#python tls verification