Troubleshooting
HTTPS, TLS & DNS

Fix CERTIFICATE_VERIFY_FAILED for Outbound Django Requests

Fix SSL CERTIFICATE_VERIFY_FAILED when Django calls external APIs: incomplete server chains, missing CA bundles in containers, certifi versus the system store, internal CAs, proxies, and clock skew.

Problem statement

A view, Celery task, or webhook call from Django to an external HTTPS service fails in production with:

requests.exceptions.SSLError: HTTPSConnectionPool(host='api.partner.example', port=443):
Max retries exceeded with url: /v1/orders (Caused by SSLError(SSLCertVerificationError(1,
'[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable to get local issuer certificate (_ssl.c:1006)')))

The same URL opens fine in a browser, and the call works on a laptop. Python is refusing the connection because it cannot build a trusted chain from the server's certificate to a root CA it knows. That is a security feature; the fix is to give Python the right trust information, never to disable verification. This guide finds which piece is missing.

Building a trusted chainServer certificateapi.partner.exampleIntermediate CAsent by server?Root CAin the trust store?Python sslverify or failBrowsers repair broken chains; Python does not, so a chain that works in Chrome can fail here.
  1. Test the server's chain with openssl s_client -connect host:443 -servername host -showcerts and read the verify result.
  2. If the server does not send its intermediate certificate, the service owner must fix their chain; meanwhile you can trust the intermediate explicitly.
  3. In containers, make sure CA certificates are installed (ca-certificates package) and certifi is current.
  4. If the target uses an internal or corporate CA, add that CA to a bundle and point REQUESTS_CA_BUNDLE (or verify=) at it, or use the truststore package to trust the OS store.
  5. Check the server clock with timedatectl; a wrong date makes valid certificates look invalid.
  6. Never ship verify=False.

Step-by-step solution

1. Inspect the chain the server sends

openssl s_client -connect api.partner.example:443 -servername api.partner.example -showcerts </dev/null 2>/dev/null \
  | grep -E "^ *[0-9] s:|^ *i:|Verify return code"
every link in the chain must connectLeaf: api.partner.examplesent by the serverIntermediate CAmust also be sent by the serverRoot CAmust be in your trust storeA server that omits the intermediate works in browsers and fails in Pythondjango-deployment.com
Browsers fetch missing intermediates themselves; Python does not.

Read the output:

  • Verify return code: 0 (ok): the chain is fine for OpenSSL's store on this machine; the problem is likely which CA bundle Python uses (step 3).
  • Verify return code: 20 (unable to get local issuer certificate) with only one certificate listed: the server sends its leaf certificate without the intermediate. Browsers often fetch the missing intermediate automatically (using the AIA extension) or have it cached; Python does not.
  • Verify return code: 19 (self-signed certificate in certificate chain): an internal CA or an intercepting proxy (step 4).
  • certificate has expired or not yet valid: check the server's certificate dates and your own clock (step 5).

2. Incomplete chain on the remote server

The correct fix is on the service's side: they must configure their server to send the full chain (leaf plus intermediates). Report it with the openssl output.

Until they fix it, you can trust the intermediate for that host only, by building a bundle containing the standard roots plus the missing intermediate:

python -c "import certifi; print(certifi.where())"
cat "$(python -c 'import certifi; print(certifi.where())')" partner-intermediate.pem > /srv/myapp/ca/partner-bundle.pem
PARTNER_CA_BUNDLE = "/srv/myapp/ca/partner-bundle.pem"
requests.get("https://api.partner.example/v1/orders", verify=PARTNER_CA_BUNDLE, timeout=10)

Download the intermediate from the issuing CA's official site, not from the misconfigured server. Remove the workaround once the service is fixed.

3. Which trust store is Python using?

requests (and httpx) use the certifi bundle by default, not the operating system's store. The standard library's urllib and smtplib use OpenSSL's default paths, which usually point to the system store.

python -c "import certifi; print(certifi.where())"
python -c "import ssl; print(ssl.get_default_verify_paths())"
pip show certifi | grep Version

Common problems:

  • Old certifi pinned for years lacks newer root CAs and still contains removed ones. Upgrade it as part of regular dependency updates.
  • Container images without CA certificates: minimal images (for example some distroless or scratch-based builds, or Alpine without ca-certificates) have no system store, so urllib and SMTP connections fail. Install the package:
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && rm -rf /var/lib/apt/lists/*
  • An environment variable pointing at a stale bundle: check env | grep -E "REQUESTS_CA_BUNDLE|SSL_CERT_FILE|CURL_CA_BUNDLE". A leftover REQUESTS_CA_BUNDLE pointing to a file containing only a corporate CA breaks every public HTTPS call.

4. Internal CAs and TLS-inspecting proxies

Services inside your organisation often use certificates from a private CA, and corporate networks sometimes intercept HTTPS with a proxy that re-signs traffic. Python must trust that CA.

whose CA list is used?Trustsrequestscertifi bundlehttpxcertifi bundleurllib, smtplibsystem store via OpenSSLOverride✓REQUESTS_CA_BUNDLE / SSL_CERT_FILEAdding an internal CA to the OS store does not help requestsdjango-deployment.com
The truststore package makes requests use the OS store instead.

Option A: add the CA to the OS store and make Python use it with truststore:

sudo cp internal-root-ca.crt /usr/local/share/ca-certificates/internal-root-ca.crt
sudo update-ca-certificates
pip install truststore
# config/wsgi.py and celery.py, before any HTTPS client is created
import truststore

truststore.inject_into_ssl()

Option B: point requests at a combined bundle:

cat "$(python -c 'import certifi; print(certifi.where())')" internal-root-ca.crt > /srv/myapp/ca/bundle.pem
# in the service environment
REQUESTS_CA_BUNDLE=/srv/myapp/ca/bundle.pem

Include the public roots in the combined bundle, or calls to public APIs fail instead.

5. Check the clock

timedatectl
date -u

A server clock that is days off makes certificates appear expired or not yet valid. Enable time synchronisation (timedatectl set-ntp true with systemd-timesyncd or chrony).

6. Do not disable verification

verify=False (or ssl._create_unverified_context()) makes the error disappear and removes protection against impersonation. Anyone on the network path can then read and modify API calls, including credentials in headers. Warnings from urllib3 about unverified requests in logs are a sign this has been done somewhere.

verify=False is not a fixverify=False✕any certificate accepted✕API keys sent to impostors✕stays in the code for yearsFix the trust✓ask the provider to send the chain✓add the internal CA to a bundle✓keep certifi and ca-certificates currentA verification error is the check doing its jobdjango-deployment.com
Also check the server clock: a wrong date fails every certificate.

Verification check:

python - <<'EOF'
import requests
r = requests.get("https://api.partner.example/v1/health", timeout=10)
print(r.status_code)
EOF

Run it as the service user with the service's environment (and inside the container for Docker deployments). It should succeed without verify=False.

Explanation

TLS verification needs a chain: the server's certificate, signed by one or more intermediates, ending at a root the client already trusts. Servers are supposed to send the leaf and intermediates; clients supply the roots. Python fails when a link is missing: the server omits an intermediate, the client's root store is missing, outdated, or replaced, the CA is private, or the clock makes a valid certificate look invalid. Identifying which link is missing with openssl s_client usually takes a minute and points directly at the fix.

Edge cases / notes

Email over TLS

smtplib with EMAIL_USE_TLS uses the system store through OpenSSL. SMTP certificate errors in Django email sending follow the same diagnosis, with openssl s_client -starttls smtp -connect host:587.

Pinning certificates

Some integrations recommend pinning the partner's CA. It improves security but breaks when they rotate certificates; document the pin and monitor its expiry.

When to automate this

Keep certifi and ca-certificates current through dependency and base image updates, ship internal CA bundles through configuration management, and add a startup check that calls critical external endpoints with verification enabled, so trust problems appear at deploy time rather than in user-facing errors.

Next steps

FAQ

Why does the URL work in my browser but fail in Python?

Browsers can fetch missing intermediate certificates and use the OS trust store. Python's requests uses the certifi bundle and does not fetch missing intermediates.

Is it OK to use verify=False in production?

No. It disables protection against man-in-the-middle attacks. Fix the trust chain instead.

How do I make requests trust my company's internal CA?

Create a bundle with the public roots plus the internal CA and set REQUESTS_CA_BUNDLE, or install the CA in the OS store and use truststore.

Why did CERTIFICATE_VERIFY_FAILED start suddenly?

The remote service changed certificates or CA, a root expired, certifi is outdated, or the server clock drifted. openssl s_client shows which.

Keep going