Python SSL エラーは、requestsもpipも止める。原因は証明書の失効・バンドルの古さ・プロキシ設定の誤りなど6種類以上に分岐する。
この記事では、SSL証明書エラーの代表的な症状ごとに、原因と修正コードをセットで解説する。コピペで即対処できる構成にした。
この記事で分かること
- CERTIFICATE_VERIFY_FAILED / WRONG_VERSION_NUMBER など主要6エラーの原因
- requests・http.client・pyOpenSSL・sslモジュール別の正しい修正コード
- 複数ドメインの証明書期限を自動チェックするスクリプト(本番運用可)
SSL証明書エラー早見テーブル
まずエラーメッセージで行を特定し、「対応セクション」のリンクに飛ぶ。
| エラーメッセージ(抜粋) | 主な原因 | 解決策(一言) | 対応セクション |
|---|---|---|---|
CERTIFICATE_VERIFY_FAILED |
CA束が古い/自己署名証明書 | certifiを更新 or cafile=を指定 |
§2 |
SSL: WRONG_VERSION_NUMBER |
HTTPポートにHTTPSで接続/プロキシのプロトコル不一致 | URLスキームとプロキシ設定を修正 | §3 |
certificate has expired |
サーバー証明書の有効期限切れ | pyOpenSSLで期限事前確認・更新 | §4 |
hostname mismatch |
SANにアクセス先ホスト名が含まれていない | SAN一覧をコードで確認 | §5 |
unable to get local issuer certificate |
中間CA証明書がサーバーから送られていない | フルチェーンのPEMをcafile=に渡す |
§2 |
SSLV3_ALERT_HANDSHAKE_FAILURE |
TLSバージョン・暗号スイートの不一致 | ssl.SSLContextでTLS最低バージョンを指定 |
§6 |
EOF occurred in violation of protocol |
サーバーがTLSハンドシェイク中に切断 | OpenSSLバージョンとurllib3を最新化 | §6 |
CERTIFICATE_VERIFY_FAILED
Python requestsで最も頻繁に踏むエラー。原因によって対処が完全に異なるため、以下の3パターンを順番に確認する。
自己署名証明書・プライベートCAを使っている場合
社内API・開発サーバーなど、公的CAが発行していない証明書を使うサーバーに接続するときに発生する。verify=Falseは応急処置であり、中間者攻撃を完全に無防備にする。必ずcafile=でCA証明書を指定する。
【実行前】/path/to/ca.pemにサーバーの自己署名CA証明書(PEM形式)を用意しておく。
# ファイル名: connect_private_ca.py
import requests
CA_CERT = "/path/to/ca.pem" # サーバー管理者から取得したPEM
response = requests.get("https://internal.example.local/api", verify=CA_CERT)
print(response.status_code) # 200 が返れば成功
【実行後】200が出力される。解消されない場合、渡しているPEMが中間CA止まりでルートCAが含まれていない可能性がある。フルチェーンPEMを用意する。
環境変数でプロジェクト全体に適用する場合は、.envに以下を追加する。
# ファイル名: .env
REQUESTS_CA_BUNDLE=/path/to/ca.pem
certifiバンドルが古い場合
一般的な公開サイト(GitHub・AWS等)で突然CERTIFICATE_VERIFY_FAILEDが出た場合、certifiパッケージのルートCAバンドルが期限切れになっている。
【実行前】現在のcertifiバージョンを確認してから更新する。
# ファイル名: update_certifi.sh
python -m pip show certifi
pip install --upgrade certifi
python -c "import certifi; print(certifi.where())"
【実行後】certifiのバージョンとcacert.pemのフルパスが出力される。その後、Pythonを再起動してリクエストを再試行する。
# ファイル名: check_certifi.py
import ssl
import certifi
ctx = ssl.create_default_context(cafile=certifi.where())
print("CA bundle:", certifi.where())
print("CA count :", len(ctx.get_ca_certs()))
【実行後】使用中のCA束のパスと登録されているCA証明書の件数が出力される。
macOSのPython特有の問題
python.orgからインストールしたmacOS版Pythonは、システムのキーチェーンではなく独自の証明書バンドルを使う。インストール直後はバンドルが空のため、すべてのHTTPS通信が失敗する。
【実行前】ターミナルで下記を実行する(バージョン番号は環境に合わせて変更)。
# ファイル名: fix_macos_certs.sh
open /Applications/Python\ 3.13/Install\ Certificates.command
【実行後】ターミナルにpip install --upgrade certifiの実行ログが流れ、cacert.pemへのシンボリックリンクが作成される。これ以降、requestsのSSL検証が正常に機能する。
SSL: WRONG_VERSION_NUMBER
このエラーの実体は「TLSハンドシェイクを開始したら、相手がHTTPの平文レスポンスを返してきた」状態だ。原因は3パターンに絞られる。
- HTTPポート(80番)に対してHTTPSで接続している:URLが
https://なのにサーバーが80番でHTTPしか受け付けていない - プロキシのプロトコル指定が間違っている:
proxiesのhttpsキーの値にhttps://proxy:portを書いているが、正しくはhttp://proxy:portになる - Charles / FiddlerなどMITMツールのSSL証明書が信頼されていない:ツールのCA証明書をPythonに渡す必要がある
【実行前】プロキシ経由で外部APIに接続する想定。
# ファイル名: fix_wrong_version.py
import requests
# 誤り: https://proxy を指定するとWRONG_VERSION_NUMBERが発生する
# proxies = {"https": "https://127.0.0.1:8888"} # NG
# 正解: プロキシのスキームは http:// で統一する
proxies = {
"http": "http://127.0.0.1:8888",
"https": "http://127.0.0.1:8888",
}
response = requests.get("https://httpbin.org/get", proxies=proxies)
print(response.status_code) # 200
【実行後】200が返る。Charles/Fiddlerのような検査プロキシを使う場合は、さらにそのツールのCA証明書をverify=に指定する。
# ファイル名: fix_wrong_version_mitm.py
import requests
proxies = {"http": "http://127.0.0.1:8888", "https": "http://127.0.0.1:8888"}
response = requests.get(
"https://httpbin.org/get",
proxies=proxies,
verify="/path/to/charles-ssl-proxying-certificate.pem"
)
print(response.status_code)
【実行後】証明書パスが正しければ200が返る。
certificate has expired
接続先サーバーの証明書が有効期限切れになっているときに発生する。自分が管理するサーバーであれば証明書を更新するだけだが、外部サービスの場合は事前に期限を監視して準備する。
pyOpenSSLで有効期限を事前確認する
【実行前】pip install pyOpenSSLが完了していること。
# ファイル名: check_expiry.py
import ssl
import OpenSSL
import datetime
def get_expiry(host: str, port: int = 443) -> datetime.datetime:
raw_cert = ssl.get_server_certificate((host, port))
x509 = OpenSSL.crypto.load_certificate(OpenSSL.crypto.FILETYPE_PEM, raw_cert)
expiry_bytes = x509.get_notAfter()
expiry_str = expiry_bytes.decode("ascii")
return datetime.datetime.strptime(expiry_str, "%Y%m%d%H%M%SZ")
expiry = get_expiry("example.com")
days_left = (expiry - datetime.datetime.utcnow()).days
print(f"有効期限: {expiry.strftime('%Y-%m-%d')} (残り {days_left} 日)")
【実行後】有効期限: 2026-12-31 (残り 185 日) のように残日数が出力される。
証明書自動更新スクリプトの骨格
【実行前】opensslコマンドとCSR・CA秘密鍵が用意されていること。
# ファイル名: renew_cert.sh
#!/bin/bash
DOMAIN="internal.example.local"
DAYS=365
CA_KEY="/etc/ssl/ca/ca.key"
CA_CERT="/etc/ssl/ca/ca.pem"
OUT_DIR="/etc/ssl/certs"
openssl req -newkey rsa:2048 -nodes \
-keyout "${OUT_DIR}/${DOMAIN}.key" \
-subj "/CN=${DOMAIN}" \
-out "${OUT_DIR}/${DOMAIN}.csr"
openssl x509 -req -days ${DAYS} \
-in "${OUT_DIR}/${DOMAIN}.csr" \
-CA "${CA_CERT}" -CAkey "${CA_KEY}" -CAcreateserial \
-out "${OUT_DIR}/${DOMAIN}.crt"
echo "更新完了: $(openssl x509 -noout -enddate -in ${OUT_DIR}/${DOMAIN}.crt)"
【実行後】更新完了: notAfter=Dec 31 23:59:59 2027 GMT のように新しい有効期限が表示される。
hostname mismatch / SSL: CERTIFICATE_VERIFY_FAILED (hostname)
証明書のSAN(Subject Alternative Name)フィールドに、アクセスしているホスト名が含まれていない場合に発生する。古い証明書はCN(Common Name)のみで発行されていることがあり、現代のOpenSSLはCNを検証しないためこのエラーになる。
SANの内容をコードで確認する
【実行前】接続先のホスト名を変数に入れて実行する。
# ファイル名: check_san.py
import ssl
import socket
def get_san(host: str, port: int = 443) -> list:
ctx = ssl.create_default_context()
with ctx.wrap_socket(socket.socket(), server_hostname=host) as conn:
conn.connect((host, port))
cert = conn.getpeercert()
return [v for _, v in cert.get("subjectAltName", [])]
sans = get_san("example.com")
print("SAN一覧:", sans)
target = "api.example.com"
print(f"{target} は証明書に含まれている: {target in sans}")
【実行後】SAN一覧: ['example.com', '*.example.com', 'www.example.com'] のように出力される。アクセスしたいホスト名がリストに存在しない場合、証明書を再発行してSANに追加する必要がある。
ワイルドカード証明書(*.example.com)は1レベルのサブドメインにしか有効でない。api.sub.example.comのような2レベルのサブドメインには別途SAN追加が必要になる。
ライブラリ別 対処法比較
同じエラーでも呼び出しているライブラリによって修正箇所が変わる。下表で自分の環境に合ったライブラリ行を参照する。
| ライブラリ | CA証明書の指定方法 | TLSバージョン指定 | タイムアウト設定 |
|---|---|---|---|
requests |
verify="/path/to/ca.pem" または REQUESTS_CA_BUNDLE環境変数 |
直接指定不可。HTTPAdapter+SSLContextを使う |
timeout=(connect, read) |
http.client |
ssl.create_default_context(cafile=)をcontext=に渡す |
context.minimum_version = ssl.TLSVersion.TLSv1_2 |
HTTPSConnection(timeout=10) |
pyOpenSSL |
ctx.load_verify_locations(cafile=) |
ctx.set_min_proto_version(OpenSSL.SSL.TLS1_2_VERSION) |
conn.settimeout(10) |
ssl(標準) |
ssl.create_default_context(cafile=) |
ctx.minimum_version = ssl.TLSVersion.TLSv1_2 |
socket.settimeout(10) |
requestsでTLSバージョンを強制したい場合のコードは以下のとおり。
【実行前】TLS 1.2以上を強制するHTTPAdapterのサブクラスを定義する。
# ファイル名: requests_tls_adapter.py
import ssl
import requests
from requests.adapters import HTTPAdapter
class TLSAdapter(HTTPAdapter):
def init_poolmanager(self, *args, **kwargs):
ctx = ssl.create_default_context()
ctx.minimum_version = ssl.TLSVersion.TLSv1_2
kwargs["ssl_context"] = ctx
super().init_poolmanager(*args, **kwargs)
session = requests.Session()
session.mount("https://", TLSAdapter())
response = session.get("https://example.com")
print(response.status_code) # 200
【実行後】200が出力される。TLS 1.0/1.1しか話せないサーバーに接続しようとするとSSLV3_ALERT_HANDSHAKE_FAILUREで拒否される(意図した動作)。
恒久対策:証明書管理を自動化するコード
複数ドメインを本番運用する場合、手動確認は必ず漏れが生じる。以下のスクリプトをcronやGitHub Actionsで定期実行して、期限切れを事前に検知する。
【実行前】pip install pyOpenSSLを実行し、DOMAINSリストに監視対象を記入する。
# ファイル名: cert_monitor.py
import ssl
import socket
import datetime
import sys
DOMAINS = [
"example.com",
"api.example.com",
"internal.example.local",
]
WARN_DAYS = 30
def check_cert(host: str, port: int = 443, timeout: int = 5) -> dict:
try:
ctx = ssl.create_default_context()
with ctx.wrap_socket(
socket.create_connection((host, port), timeout=timeout),
server_hostname=host
) as conn:
cert = conn.getpeercert()
not_after = datetime.datetime.strptime(
cert["notAfter"], "%b %d %H:%M:%S %Y %Z"
)
days_left = (not_after - datetime.datetime.utcnow()).days
return {"host": host, "expiry": not_after, "days_left": days_left, "error": None}
except Exception as e:
return {"host": host, "expiry": None, "days_left": -1, "error": str(e)}
exit_code = 0
for domain in DOMAINS:
result = check_cert(domain)
if result["error"]:
print(f"[ERROR] {domain}: {result['error']}")
exit_code = 2
elif result["days_left"] <= WARN_DAYS:
print(f"[WARN] {domain}: 残り {result['days_left']} 日 (期限: {result['expiry'].strftime('%Y-%m-%d')})")
exit_code = 1
else:
print(f"[OK] {domain}: 残り {result['days_left']} 日 (期限: {result['expiry'].strftime('%Y-%m-%d')})")
sys.exit(exit_code)
【実行後】ドメインごとに [OK] example.com: 残り 210 日 (期限: 2027-01-25) のように状態が出力される。終了コードが0なら全ドメイン正常、1なら警告あり、2なら接続エラーありとなる。
# ファイル名: crontab_example
# 毎日09:00に実行し、ログをファイルに記録する
0 9 * * * /usr/bin/python3 /opt/scripts/cert_monitor.py >> /var/log/cert_monitor.log 2>&1
【実行後】crontab に登録後、翌朝9時から自動実行される。/var/log/cert_monitor.logに結果が蓄積される。
関連記事:[Python SSL証明書監視ツール]