/개발/Fixing SSLCertVerificationError: Diagnosing 5 Causes of CERTIFICATE_VERIFY_FAILED
DevelopmentSSLCertVerificationErrorCERTIFICATE_VERIFY_FAILED

Fixing SSLCertVerificationError: Diagnosing 5 Causes of CERTIFICATE_VERIFY_FAILED

Diagnose Python requests SSLCertVerificationError CERTIFICATE_VERIFY_FAILED (unable to get local issuer certificate) in 30 seconds using verify codes. Copy-pasteable fixes cover REQUESTS_CA_BUNDLE, SSL_CERT_FILE, truststore, and corporate r

Fixing SSLCertVerificationError: Diagnosing 5 Causes of CERTIFICATE_VERIFY_FAILED

Why the browser and curl work but Python fails

This is Part 7 of the Python development guide. This installment covers the Python HTTPS error most often reported in corporate-proxy and closed-network environments.

A typical traceback looks like this.

TEXT
Traceback (most recent call last):
  File ".../urllib3/connectionpool.py", line 715, in urlopen
  File ".../urllib3/connectionpool.py", line 1058, in _validate_conn
  File ".../urllib3/connection.py", line 419, in connect
  File ".../ssl.py", line 517, in wrap_socket
  File ".../ssl.py", line 1108, in _create
ssl.SSLCertVerificationError: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed:
unable to get local issuer certificate (_ssl.c:1000)

During handling of the above exception, another exception occurred:
requests.exceptions.SSLError: HTTPSConnectionPool(host='api.example.com', port=443):
Max retries exceeded with url: /v1/ping
(Caused by SSLError(SSLCertVerificationError(1, '[SSL: CERTIFICATE_VERIFY_FAILED] ...')))

I'll pin this article's conclusion in one line first.

The OS trust store ≠ the certifi bundle. Browsers and curl consult the OS (the Windows certificate store, macOS Keychain, Linux /etc/ssl/certs), but Python requests by default only looks at the certifi PEM file shipped with the package. That is why Python fails on its own even after corporate IT has deployed the root CA to the OS.

If your company runs SSL inspection appliances such as Zscaler, Netskope, or Palo Alto, this symptom is closer to the default than an exception. The appliance terminates TLS in the middle and re-signs with a corporate root CA that Python does not know about.

Five-way error-text decision table: the verify code number names the culprit

The number in parentheses after the error message, or Verify return code: NN from openssl s_client output, is the decisive clue.

Error textverify codeActual causeFirst-priority actionCheck command
unable to get local issuer certificate20Corporate root CA is missing from certifi, or the server is not sending the intermediate certificateLoad the root CA into Python (Section 4)openssl s_client -connect host:443 -showcerts
self signed certificate in certificate chain19Corporate MITM proxy is re-signing trafficRegister the proxy root CA (Section 4 b/d)Check whether the top-of-chain Issuer is Zscaler/Netskope
certificate has expired10Server certificate expired, an expired cross-sign path was selected (DST Root CA X3 leftovers), or system clock skewInspect the server chain + check dateopenssl s_client ... | openssl x509 -noout -dates
Hostname mismatch, certificate is not valid for 'x.y.z'SNI not sent, connecting by IP, or wildcard depth (*.a.com does not cover b.c.a.com)Connect by domain or check SANopenssl x509 -noout -text | grep -A1 "Subject Alternative Name"
unable to get local issuer certificate on macOS only20python.org installer: Install Certificates.command was never run → certifi link missingRun that script once (Section 4, OS-specific)python3 -c "import certifi;print(certifi.where())"

Raw messages are also left in a code block for searchability.

TEXT
[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable to get local issuer certificate
[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: self signed certificate in certificate chain
[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: certificate has expired
[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: Hostname mismatch, certificate is not valid for 'api.example.com'

30-second diagnosis: pin the cause with three commands

① Confirm the bundle path Python actually uses

Bash
python -c "import ssl, certifi; print(certifi.where()); print(ssl.OPENSSL_VERSION)"

Expected healthy output:

TEXT
/usr/lib/python3.11/site-packages/certifi/cacert.pem
OpenSSL 3.0.13 30 Jan 2024
  • The path prints but it still fails → that bundle does not contain the corporate CA. Go to Section 4.
  • ModuleNotFoundError: certifipip install certifi, or you are in the wrong virtualenv.
  • If multiple virtualenvs are mixed up, pin the interpreter first with python -c "import sys;print(sys.executable)".

② Confirm the chain the server actually presents

Bash
openssl s_client -connect api.example.com:443 -servername api.example.com -showcerts </dev/null 2>/dev/null \
  | grep -E "^(depth|verify|Verify| [0-9] s:| [0-9] i:)"

Healthy case (public CA):

TEXT
 0 s:CN=api.example.com
   i:C=US, O=Let's Encrypt, CN=R11
 1 s:C=US, O=Let's Encrypt, CN=R11
   i:C=US, O=Internet Security Research Group, CN=ISRG Root X1
Verify return code: 0 (ok)

Corporate SSL inspection case:

TEXT
 0 s:CN=api.example.com
   i:CN=Zscaler Intermediate Root CA (zscaler.net)
 1 s:CN=Zscaler Intermediate Root CA (zscaler.net)
   i:CN=Zscaler Root CA
Verify return code: 19 (self signed certificate in certificate chain)

If the Issuer (i:) shows a company name or security-appliance name, diagnosis is done. MITM proxy environment → load the corporate root CA into Python.

If the chain shows only depth 0 and Verify return code: 20, the server omitted the intermediate certificate. The proper fix is to ask the server admin to deploy the full chain (fullchain). If this is a certificate issuance/renewal issue, also see Fixing certbot renewal failures: troubleshooting certificate expired by cause.

③ Compare curl vs Python to confirm the split architecture

Bash
curl -v https://api.example.com/ 2>&1 | grep -E "CAfile|SSL certificate|subject|issuer"
python - <<'PY'
import requests
try:
    requests.get("https://api.example.com/", timeout=5)
    print("PY OK")
except Exception as e:
    print("PY FAIL:", e)
PY
curlPythonDiagnosis
SuccessFailCA is in the OS store but not in certifi → Section 4 (b)(d) recommended
FailFailCA is missing from the OS as well → start with OS-level root registration in Section 4
SuccessSuccessCode / proxy environment-variable issue → Section 6 failure branches

Note that Python's CA configuration is completely separate from Java's -Djavax.net.ssl.trustStore family of options. If you hit the same symptom on a Java stack, see PKIX path building failed / SunCertPathBuilderException 30-minute runbook.

The proper fix by environment: four ways to load a corporate root CA into Python

(a) Priority order: verify= and the three environment variables

VariableScopeNotes
verify= argumentThat request/sessionAlways highest priority. If hardcoded in code, env vars are ignored
REQUESTS_CA_BUNDLErequests family onlyRead directly by requests. Takes precedence over CURL_CA_BUNDLE
CURL_CA_BUNDLEcurl + requests fallbackUsed by requests when REQUESTS_CA_BUNDLE is unset
SSL_CERT_FILE / SSL_CERT_DIRPython ssl · OpenSSL globalApplies across standard contexts: aiohttp, httpx, urllib, etc.

Priority is verify=REQUESTS_CA_BUNDLECURL_CA_BUNDLE → (requests default: certifi). Libraries that do not use requests look at SSL_CERT_FILE. If you are rolling this out company-wide, SSL_CERT_FILE has broader coverage.

On Python 3.10+, truststore makes Python use the OS trust store as-is. If corporate IT has already deployed the root CA to the OS, you do not need to manage extra files.

Bash
pip install truststore
Python
import truststore
truststore.inject_into_ssl()   # 이 이후 생성되는 모든 SSLContext가 OS 저장소 사용

import requests
print(requests.get("https://api.example.com/", timeout=5).status_code)

Call it once at the application entry point (top of main.py, Django settings.py, and so on). Recent pip versions also support using the system store via --use-feature=truststore and similar flags, so check the official docs for your pip version for option support, then apply it.

(c) pip-system-certs

Bash
pip install pip-system-certs

Once installed, it patches requests/pip to use the system store. Useful for third-party CLI tools you cannot modify, but it monkey-patches at import time, so for production services where you want the behavior to be explicit, prefer (b).

(d) Bundle merge — never edit certifi's original file

If you edit certifi's cacert.pem directly, it gets wiped the moment you pip install --upgrade certifi. Create a separate merged file.

Bash
sudo mkdir -p /opt/ca
cat "$(python -m certifi)" /path/to/corp-root.crt > /opt/ca/corp-bundle.pem

# 전역 적용
export SSL_CERT_FILE=/opt/ca/corp-bundle.pem
export REQUESTS_CA_BUNDLE=/opt/ca/corp-bundle.pem

# 검증
python -c "import requests;print(requests.get('https://api.example.com/',timeout=5).status_code)"

If things are healthy, it prints 200. If it still fails, check that the merged file contains at least two -----BEGIN CERTIFICATE----- blocks, and that the corporate CA is not in DER format. If it is DER, convert it.

Bash
openssl x509 -inform der -in corp-root.der -out corp-root.crt

OS-specific root CA registration

Bash
# Ubuntu / Debian
sudo cp corp-root.crt /usr/local/share/ca-certificates/corp-root.crt
sudo update-ca-certificates          # "1 added" 출력이 정상

# RHEL / Rocky
sudo cp corp-root.crt /etc/pki/ca-trust/source/anchors/
sudo update-ca-trust extract
Bash
# macOS: python.org 설치본이라면 먼저 이것
/Applications/Python\ 3.12/Install\ Certificates.command
# 키체인 등록
sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain corp-root.crt
POWERSHELL
# Windows (관리자 PowerShell)
certutil -addstore -f "Root" C:\certs\corp-root.crt
Import-Certificate -FilePath C:\certs\corp-root.crt -CertStoreLocation Cert:\LocalMachine\Root

Comparison of the four methods

MethodCode changeReinstall resilienceCoverageRecommended rank
truststore2 lines neededStrong (OS-based)Standard ssl overall★1
OS root registration + truststoreNone (when combined)StrongAll OS tools★1
Bundle merge + SSL_CERT_FILENoneStrong (separate file)Python overall★2
pip-system-certsNoneMediumrequests/pip★3
Direct certifi editNoneNonerequests onlyForbidden

The real risk of verify=False

verify=False means "do not verify the certificate," not "connect securely." If the same code runs on cafe Wi-Fi or an external network, anyone can insert themselves in the middle with their own certificate and read API keys in request headers and the response body as-is. The assumption that it will only run inside the corporate proxy breaks as soon as a single deployment environment changes.

If you must use it temporarily, leave at least this much behind.

Python
# ⚠️ TODO(2026-08-31 제거): 폐쇄망 스테이징 전용. 운영 반영 금지.
# 사내 루트 CA 배포(INFRA-1234) 완료 후 verify=/opt/ca/corp-bundle.pem 으로 교체
import urllib3, requests
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
requests.get("https://internal.example.local/health", verify=False, timeout=5)

Code that disables the warning is a sign you hid the problem. Do not commit it without an expiry comment and a ticket number.

When pip install throws the same error

Bash
pip install requests \
  --trusted-host pypi.org \
  --trusted-host files.pythonhosted.org \
  --trusted-host pypi.python.org

For a permanent setting, put it in the config file. (Linux/macOS ~/.config/pip/pip.conf, Windows %APPDATA%\pip\pip.ini)

INI
[global]
cert = /opt/ca/corp-bundle.pem
index-url = https://pypi.org/simple

[install]
trusted-host =
    pypi.org
    files.pythonhosted.org

cert= (the proper fix) and trusted-host (verification bypass) are different in nature. Prefer cert= whenever you can.

When it only fails in Docker

python:3.12-slim and Alpine-based images often omit or minimize the CA bundle package, so failures that only happen in containers are common.

Dockerfile
FROM python:3.12-slim

RUN apt-get update \
 && apt-get install -y --no-install-recommends ca-certificates \
 && rm -rf /var/lib/apt/lists/*

# 사내 루트 CA 반영
COPY corp-root.crt /usr/local/share/ca-certificates/corp-root.crt
RUN update-ca-certificates

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

For Alpine, use RUN apk add --no-cache ca-certificates && update-ca-certificates. If you are already blocked at image build time (pip install), place the CA copy before pip install.

Per-library configuration cheat sheet

LibrarySpecify corporate CADisable verification (not recommended)
requestsrequests.get(url, verify="/opt/ca/corp-bundle.pem")verify=False
httpxhttpx.Client(verify="/opt/ca/corp-bundle.pem")verify=False
aiohttpssl=ssl.create_default_context(cafile="/opt/ca/corp-bundle.pem")ssl=False
urllib3 2.xPoolManager(ca_certs="/opt/ca/corp-bundle.pem")cert_reqs="CERT_NONE"
stdlib urlliburlopen(url, context=ssl.create_default_context(cafile=...))
Python
import ssl, aiohttp

ctx = ssl.create_default_context(cafile="/opt/ca/corp-bundle.pem")

async def fetch(url):
    async with aiohttp.ClientSession() as s:
        async with s.get(url, ssl=ctx) as r:
            return await r.text()

From urllib3 2.x onward, OpenSSL 1.1.1+ is required and TLS policy is stricter. If you hit SSLError: [SSL: UNSUPPORTED_PROTOCOL] or a handshake failure while talking to older equipment, it is a protocol negotiation issue, not a CA issue, so Section 4 of this article will not fix it. Bumping the server-side TLS version is the proper fix.

When it still fails: three failure branches

  1. Misconfigured proxy environment variables — Check HTTPS_PROXY and NO_PROXY with env | grep -i proxy. A common case is a corporate API missing from NO_PROXY, so traffic goes through the proxy and gets re-signed. Uppercase vs lowercase variants (https_proxy) set to different values are also reported often.
  2. System clock skew — If a container or VM clock is badly skewed, even a valid certificate surfaces as certificate has expired (err 10). Compare date -u with actual UTC and confirm NTP sync.
  3. OpenSSL 3.x policy differences — You can fail due to blocked legacy renegotiation with older servers, rejected SHA-1 signatures, and similar. In that case the error text changes to something other than CERTIFICATE_VERIFY_FAILED, so re-read the raw message first.

Recapping the diagnosis flow:

TEXT
에러 원문 확인
 ├ unable to get local issuer (20) ─ openssl s_client 로 체인 확인
 │    ├ Issuer가 사내/보안장비 → 루트 CA 등록 (truststore 또는 번들 병합)
 │    └ 체인 1개뿐 → 서버에 fullchain 배포 요청
 ├ self signed in chain (19) ────── 사내 MITM 확정 → 루트 CA 등록
 ├ certificate has expired (10) ─── date 확인 → 서버 인증서 만료일 확인
 └ hostname mismatch ───────────── SAN/SNI/IP 접속 여부 확인

Go or Docker x509: certificate signed by unknown authority, and Java PKIX errors, share the same causal structure, but the file paths you fix are completely different. See the per-stack articles separately.

Next installment (Python Development Guide, Part 8) covers ModuleNotFoundError / ImportError — how to fully diagnose Python import-path problems from the angles of sys.path, package layout, and virtualenvs.

Frequently asked questions (FAQ)

Q. curl works but only Python throws an SSL error. Why? A. Because curl uses the OS trust store, while requests by default uses the certifi bundle shipped with the package. If corporate IT deploys the root CA only to the OS, Python never sees that CA. Use truststore.inject_into_ssl() or point SSL_CERT_FILE at a merged bundle path.

Q. I set REQUESTS_CA_BUNDLE and it still fails. A. If verify= is hardcoded in code, it takes precedence over the environment variable. Also, REQUESTS_CA_BUNDLE applies only to the requests family, so for aiohttp, httpx, and stdlib urllib you need to set SSL_CERT_FILE as well. Check file permissions too (a service running as another user may not be able to read the file).

Q. Is it OK to just pass verify=False? A. That turns verification off entirely, leaving you wide open to man-in-the-middle attacks. For short-lived uses such as closed-network testing, leave a removal deadline and ticket number in a comment, and in production code always apply the proper fix: register the corporate root CA.

확인 정보
✦ ✦ ✦
편집 검토 · Editorial Review

Nodelog는 모든 콘텐츠의 내용과 출처를 공개 전에 검토합니다. 환경(OS·버전)에 따라 결과가 달라질 수 있는 기술 정보는 공식 문서와 함께 확인하며, 검토 기준과 정정 원칙은 편집 정책에서 안내합니다. 오류를 발견하시면 이메일로 제보해 주세요 — 확인 후 신속히 정정합니다.

편집 책임 · Nodelog 기술 편집팀·발행 · ·업데이트 ·

Comments

Be the first to comment.