/개발/SSLCertVerificationError 해결: CERTIFICATE_VERIFY_FAILED 원인 5종 판별법
개발SSLCertVerificationErrorCERTIFICATE_VERIFY_FAILED

SSLCertVerificationError 해결: CERTIFICATE_VERIFY_FAILED 원인 5종 판별법

파이썬 requests의 SSLCertVerificationError CERTIFICATE_VERIFY_FAILED(unable to get local issuer certificate)를 verify code로 30초에 판정하고, REQUESTS_CA_BUNDLE·SSL_CERT_FILE·truststore·사내 루트 CA 등록까지 복붙 가능한 해결책을 정리했습니다.

SSLCertVerificationError 해결: CERTIFICATE_VERIFY_FAILED 원인 5종 판별법

브라우저와 curl은 되는데 파이썬만 실패하는 이유

파이썬 개발 가이드 7편입니다. 이번 편은 사내 프록시·폐쇄망 환경에서 가장 자주 보고되는 파이썬 HTTPS 에러를 다룹니다.

전형적인 traceback은 이렇게 생겼습니다.

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] ...')))

이 글의 결론을 한 줄로 먼저 박아두겠습니다.

OS 트러스트 스토어 ≠ certifi 번들. 브라우저와 curl은 OS(윈도우 인증서 저장소, macOS 키체인, 리눅스 /etc/ssl/certs)를 보지만, 파이썬 requests는 기본적으로 패키지에 동봉된 certifi PEM 파일만 봅니다. 그래서 사내 IT가 루트 CA를 OS에 배포해도 파이썬만 홀로 실패합니다.

Zscaler·Netskope·Palo Alto 같은 SSL 인스펙션 장비가 깔린 회사라면 이 증상은 예외가 아니라 기본값에 가깝습니다. 장비가 TLS를 중간에서 끊고 사내 루트 CA로 재서명하는데, 파이썬은 그 루트 CA를 모르기 때문입니다.

에러 원문 5종 분기표: verify code 숫자가 범인을 가리킨다

에러 메시지 뒤 괄호나 openssl s_client 출력의 Verify return code: NN 숫자가 결정적 단서입니다.

에러 원문verify code실제 원인1순위 조치확인 커맨드
unable to get local issuer certificate20사내 루트 CA가 certifi에 없음, 또는 서버가 중간(intermediate) 인증서를 안 보냄루트 CA를 파이썬에 태우기(4장)openssl s_client -connect host:443 -showcerts
self signed certificate in certificate chain19사내 MITM 프록시가 트래픽을 재서명프록시 루트 CA 등록(4장 b/d)체인 최상단 Issuer가 Zscaler/Netskope인지 확인
certificate has expired10서버 인증서 만료, 또는 만료된 크로스서명 경로 선택(DST Root CA X3 계열 잔재), 시스템 시계 오차서버 체인 점검 + date 확인openssl s_client ... | openssl x509 -noout -dates
Hostname mismatch, certificate is not valid for 'x.y.z'SNI 미전송, IP 직접 접속, 와일드카드 깊이(*.a.comb.c.a.com 불가)도메인으로 접속하거나 SAN 확인openssl x509 -noout -text | grep -A1 "Subject Alternative Name"
macOS에서만 unable to get local issuer certificate20python.org 설치본에서 Install Certificates.command 미실행 → certifi 링크 부재해당 스크립트 1회 실행(4장 OS별)python3 -c "import certifi;print(certifi.where())"

검색용으로 원문도 코드블록으로 남겨둡니다.

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초 판정 절차: 커맨드 3개로 원인 확정

① 파이썬이 실제로 보는 번들 경로 확인

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

예상 정상 결과:

TEXT
/usr/lib/python3.11/site-packages/certifi/cacert.pem
OpenSSL 3.0.13 30 Jan 2024
  • 경로가 출력되는데도 실패한다 → 그 번들에 사내 CA가 없다는 뜻. 4장으로.
  • ModuleNotFoundError: certifipip install certifi 또는 가상환경을 잘못 잡은 상태.
  • 가상환경이 여러 개 섞였다면 python -c "import sys;print(sys.executable)"로 실행 파일을 먼저 고정하세요.

② 서버가 실제로 내려주는 체인 확인

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:)"

정상(공인 인증서) 케이스:

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)

사내 SSL 인스펙션 케이스:

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)

Issuer(i:)에 회사 이름이나 보안 장비 이름이 보이면 판정 끝입니다. MITM 프록시 환경 → 사내 루트 CA를 파이썬에 태우면 됩니다.

체인이 0번 하나만 나오고 Verify return code: 20이면 서버가 중간 인증서를 누락한 것입니다. 이 경우는 서버 관리자에게 풀체인(fullchain) 배포를 요청하는 게 정공법입니다. 인증서 발급/갱신 쪽 문제라면 certbot 갱신 실패 해결: certificate expired 원인별 트러블슈팅을 함께 보세요.

③ curl과 파이썬 비교로 "분리 구조" 확정

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
curl파이썬판정
성공실패OS 저장소에는 CA가 있고 certifi에는 없음 → 4장 (b)(d) 권장
실패실패OS에도 CA가 없음 → 4장 OS별 루트 등록부터
성공성공코드/프록시 환경변수 문제 → 6장 실패 분기

참고로 파이썬의 CA 설정은 자바의 -Djavax.net.ssl.trustStore 계열 옵션과 완전히 별개입니다. 자바 스택에서 같은 증상이 났다면 PKIX path building failed / SunCertPathBuilderException 30분 해결 런북을 참고하세요.

환경별 정공법: 사내 루트 CA를 파이썬에 태우는 4가지 방법

(a) 환경변수 3형제 우선순위

변수적용 범위비고
verify= 인자해당 요청/세션항상 최우선. 코드에 하드코딩되면 환경변수가 무시됨
REQUESTS_CA_BUNDLErequests 계열만requests가 직접 읽음. CURL_CA_BUNDLE보다 우선
CURL_CA_BUNDLEcurl + requests 폴백REQUESTS_CA_BUNDLE이 없을 때 requests가 사용
SSL_CERT_FILE / SSL_CERT_DIR파이썬 ssl·OpenSSL 전역aiohttp, httpx, urllib 등 표준 컨텍스트 전반에 적용

정리하면 우선순위는 verify=REQUESTS_CA_BUNDLECURL_CA_BUNDLE → (requests 기본값 certifi) 이고, requests를 쓰지 않는 라이브러리는 SSL_CERT_FILE을 봅니다. 사내 전체에 깔 거라면 SSL_CERT_FILE이 커버리지가 넓습니다.

(b) truststore 모듈 — 2026년 기준 권장 1순위

Python 3.10+에서는 truststore가 OS 트러스트 스토어를 그대로 사용하게 해줍니다. 사내 IT가 이미 OS에 루트 CA를 배포했다면 추가 파일 관리가 필요 없습니다.

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)

애플리케이션 진입점(main.py 최상단, Django settings.py 등)에서 한 번만 호출하면 됩니다. pip 자체도 최신 버전에서 --use-feature=truststore 계열로 시스템 저장소 사용을 지원하므로, 사용 중인 pip 버전의 공식 문서에서 옵션 지원 여부를 확인하고 적용하세요.

(c) pip-system-certs

Bash
pip install pip-system-certs

설치만 하면 requests/pip이 시스템 저장소를 쓰도록 패치합니다. 코드 수정이 불가능한 서드파티 CLI 도구에 유용하지만, 임포트 시점에 몽키패치가 일어나므로 동작을 명시적으로 보고 싶은 프로덕션 서비스에는 (b)를 권합니다.

(d) 번들 병합 — certifi 원본을 절대 수정하지 말 것

certifi의 cacert.pem을 직접 편집하면 pip install --upgrade certifi 한 번에 날아갑니다. 별도 병합 파일을 만드세요.

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)"

정상이면 200이 출력됩니다. 여전히 실패하면 병합 파일에 -----BEGIN CERTIFICATE----- 블록이 두 종류 이상 들어갔는지, 그리고 사내 CA가 DER 형식이 아닌지 확인하세요. DER이면 변환이 필요합니다.

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

OS별 루트 CA 등록

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

4가지 방법 비교

방법코드 수정재설치 내성커버리지권장 순위
truststore2줄 필요강함(OS 기준)표준 ssl 전반★1
OS 루트 등록 + truststore없음(조합 시)강함OS 전체 도구★1
번들 병합 + SSL_CERT_FILE없음강함(별도 파일)파이썬 전반★2
pip-system-certs없음보통requests/pip★3
certifi 직접 수정없음없음requests만금지

verify=False는 왜 해결이 아닌가 + 파생 케이스

verify=False의 실제 위험

verify=False는 "인증서를 검증하지 않는다"이지 "안전하게 연결한다"가 아닙니다. 카페 와이파이나 외부망에서 같은 코드가 돌면, 누구든 자기 인증서로 중간에 끼어들어 요청 헤더의 API 키와 응답 본문을 그대로 읽을 수 있습니다. 사내 프록시 안에서만 돌 것이라는 가정은 배포 환경이 하나만 바뀌어도 깨집니다.

부득이하게 임시로 써야 한다면 최소한 이렇게 남기세요.

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)

경고를 끄는 코드는 "문제를 감춘 표시"입니다. 만료일 주석과 티켓 번호 없이 커밋하지 마세요.

pip install이 같은 에러를 낼 때

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

영구 설정은 설정 파일에 넣습니다. (리눅스/macOS ~/.config/pip/pip.conf, 윈도우 %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=(정공법)와 trusted-host(검증 우회)는 성격이 다릅니다. 가능하면 cert=를 쓰세요.

Docker에서만 실패할 때

python:3.12-slim, alpine 계열은 CA 번들 패키지가 빠져 있거나 최소화되어 있어 컨테이너에서만 실패하는 사례가 잦습니다.

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

알파인이라면 RUN apk add --no-cache ca-certificates && update-ca-certificates를 씁니다. 이미지 빌드 단계(pip install)에서 이미 막힌다면 CA 복사를 pip install보다 앞에 배치해야 합니다.

라이브러리별 설정 대응표

라이브러리사내 CA 지정검증 끄기(비권장)
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"
표준 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()

urllib3 2.x 이후로는 OpenSSL 1.1.1 이상이 요구되고 TLS 정책이 강화됐습니다. 구형 장비에 붙다가 SSLError: [SSL: UNSUPPORTED_PROTOCOL]이나 handshake failure가 나면 CA 문제가 아니라 프로토콜 협상 문제이므로, 이 글의 4장 조치로는 해결되지 않습니다. 서버 측 TLS 버전 상향이 정석입니다.

그래도 안 될 때: 실패 분기 3가지

  1. 프록시 환경변수 오설정env | grep -i proxyHTTPS_PROXY, NO_PROXY를 확인하세요. 사내 API가 NO_PROXY에 빠져 프록시를 타면서 재서명되는 경우가 흔합니다. 대문자/소문자 변수(https_proxy)가 서로 다르게 설정된 경우도 자주 보고됩니다.
  2. 시스템 시계 오차 — 컨테이너나 VM 시계가 크게 밀리면 유효한 인증서도 certificate has expired(err 10)로 뜹니다. date -u와 실제 UTC를 비교하고 NTP 동기화를 확인하세요.
  3. OpenSSL 3.x의 정책 차이 — 구형 서버와의 legacy renegotiation 차단, SHA-1 서명 거부 등으로 실패할 수 있습니다. 이때 에러 문구가 CERTIFICATE_VERIFY_FAILED가 아니라 다른 코드로 바뀌므로 원문을 다시 읽는 게 먼저입니다.

판정 흐름을 다시 정리하면 이렇습니다.

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나 Docker에서 나는 x509: certificate signed by unknown authority, 자바의 PKIX 오류는 원인 구조는 같지만 조치 파일 경로가 전혀 다릅니다. 스택별 글을 따로 참고하세요.

다음 편(파이썬 개발 가이드 8편)에서는 ModuleNotFoundError / ImportError — 파이썬 임포트 경로 문제를 sys.path, 패키지 레이아웃, 가상환경 관점에서 완전 판별하는 법을 다룹니다.

자주 묻는 질문 (FAQ)

Q. curl은 되는데 파이썬만 SSL 오류가 납니다. 왜 그런가요? A. curl은 OS 트러스트 스토어를, requests는 패키지에 포함된 certifi 번들을 기본으로 사용하기 때문입니다. 사내 IT가 루트 CA를 OS에만 배포하면 파이썬은 그 CA를 모릅니다. truststore.inject_into_ssl()을 쓰거나 SSL_CERT_FILE에 병합 번들 경로를 지정하면 해결됩니다.

Q. REQUESTS_CA_BUNDLE을 설정했는데도 계속 실패합니다. A. 코드에 verify= 인자가 하드코딩되어 있으면 환경변수보다 우선합니다. 또한 REQUESTS_CA_BUNDLE은 requests 계열에만 적용되므로 aiohttp·httpx·표준 urllib에는 SSL_CERT_FILE을 함께 설정해야 합니다. 파일 권한(다른 사용자로 실행되는 서비스에서 읽기 불가)도 확인하세요.

Q. verify=False로 넘겨도 되나요? A. 검증 자체를 끄는 것이라 중간자 공격에 무방비가 됩니다. 폐쇄망 테스트 등 한시적 용도라면 제거 기한과 티켓 번호를 주석으로 남기고, 운영 코드에는 반드시 사내 루트 CA를 등록하는 정공법을 적용하세요.

✦ ✦ ✦
편집 검토 · Editorial Review

AI 도구는 자료 조사와 초안 작성의 보조 수단으로 사용될 수 있습니다. Nodelog는 공개 전 내용과 출처를 검토하고, 환경(OS·버전)에 따라 결과가 달라질 수 있는 기술 정보는 공식 문서를 함께 확인하도록 안내합니다. 오류를 발견하시면 이메일로 제보해 주세요 — 확인 후 신속히 정정합니다.

편집 책임 · Nodelog 기술 편집팀·발행 ·

댓글

첫 번째 댓글을 남겨보세요.