Python SSL証明書エラー解決まとめ【2026年版】

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 OpenSSL完全ガイド]

関連記事:[Python SSL証明書監視ツール]