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.
- Test the server's chain with
openssl s_client -connect host:443 -servername host -showcertsand read the verify result. - If the server does not send its intermediate certificate, the service owner must fix their chain; meanwhile you can trust the intermediate explicitly.
- In containers, make sure CA certificates are installed (
ca-certificatespackage) andcertifiis current. - If the target uses an internal or corporate CA, add that CA to a bundle and point
REQUESTS_CA_BUNDLE(orverify=) at it, or use thetruststorepackage to trust the OS store. - Check the server clock with
timedatectl; a wrong date makes valid certificates look invalid. - 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"
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 expiredornot 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
certifipinned 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, sourlliband 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 leftoverREQUESTS_CA_BUNDLEpointing 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.
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.
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
- For email-specific failures, see Fix Django Emails Not Sending in Production.
- Include CA certificates properly in images with How to Dockerize a Django App for Production.
- Review other defaults with Django Deployment Security Basics You Should Not Skip.
- For TLS errors to the database, see Fix PostgreSQL SSL Connection Errors in Django.
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.