Fix SSL: HANDSHAKE_FAILURE — HTTPS接続時のSSLハンドシェイク失敗を解決する

intermediate🔒 SSL/TLS2026-03-18| Linux/macOS/Windows — curl, Python requests, Node.js, Java, OpenSSL 1.0.x–3.x

Error Message

SSL: HANDSHAKE_FAILURE — SSL routines:ssl3_read_bytes:sslv3 alert handshake failure
#ssl#ハンドシェイク#tls#openssl

エラーの概要

HTTPSエンドポイントにアクセスすると、以下のエラーが返ってきます:

SSL: HANDSHAKE_FAILURE — SSL routines:ssl3_read_bytes:sslv3 alert handshake failure

curlの場合:

curl: (35) OpenSSL SSL_connect: SSL_ERROR_SYSCALL in connection to example.com:443

Pythonの場合:

requests.exceptions.SSLError: HTTPSConnectionPool(host='example.com', port=443):
  Max retries exceeded ... caused by SSLError(SSLError(1, '[SSL: HANDSHAKE_FAILURE]
  ssl handshake failure (_ssl.c:997)'))

TLSハンドシェイクは、クライアントとサーバーが暗号スイートに合意し、証明書を交換するプロセスです。これが失敗すると、接続は即座に切断され、何もデータが通りません。

原因

以下の6つが、このエラーの大部分を占める原因です:

  • TLSバージョンの不一致 — サーバーはTLS 1.2以上を要求しているが、クライアントはSSLv3/TLS 1.0しか提供していない
  • 共通の暗号スイートがない — クライアントの暗号リストとサーバーの許可する暗号に重複がない
  • 古いOpenSSL — 1.1.1未満のバージョン(2019年12月にサポート終了)では、現在多くのサーバーが要求するECDHEおよびAES-GCM暗号スイートがサポートされていない
  • サーバーがSNIを要求 — クライアントがServer Name Indicationを送信しないため、サーバーがハンドシェイクを拒否する
  • 期限切れまたは破損した証明書チェーン — 証明書が期限切れ、または中間CA証明書が欠落している
  • ファイアウォール/プロキシの干渉 — 中間装置がClientHelloをサーバーに到達する前に削除または破損させる

素早い診断

推測せず、まずこれらのチェックを実行してネゴシエーションの問題箇所を特定しましょう。

ステップ1 — サーバーがサポートするTLSバージョンを確認する

# 特定のTLSバージョンをテストする
openssl s_client -connect example.com:443 -tls1_2
openssl s_client -connect example.com:443 -tls1_3

# サーバーが古いバージョンを許可しているか確認する(堅牢なサーバーでは失敗するはず)
openssl s_client -connect example.com:443 -ssl3
openssl s_client -connect example.com:443 -tls1

-tls1_2で接続できるが-tls1が失敗する場合、サーバーはTLS 1.0を廃止しています — クライアント側も対応が必要です。

ステップ2 — 暗号スイートのネゴシエーションを確認する

openssl s_client -connect example.com:443 -cipher 'ALL' 2>&1 | grep -E 'Cipher|Protocol|Alert'

ステップ3 — curlの詳細出力を確認する

curl -v --tlsv1.2 https://example.com 2>&1 | head -40

TLSv1.2, Handshake, Client helloの直後にalert handshake failureが続いていないか確認してください。このシーケンスは、サーバーがClientHelloを拒否したことを示しています。

修正1 — 互換性のあるTLSバージョンを強制指定する(即効性あり)

ほとんどの場合、TLS 1.2を強制指定するだけで即座に解決します。

curl

curl --tlsv1.2 https://example.com
# またはTLS 1.3の場合
curl --tlsv1.3 https://example.com

Python requests

import ssl
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.ssl_ import create_urllib3_context

class TLS12Adapter(HTTPAdapter):
    def init_poolmanager(self, *args, **kwargs):
        ctx = create_urllib3_context()
        ctx.minimum_version = ssl.TLSVersion.TLSv1_2
        kwargs['ssl_context'] = ctx
        return super().init_poolmanager(*args, **kwargs)

session = requests.Session()
session.mount('https://', TLS12Adapter())
response = session.get('https://example.com')

Node.js(httpsモジュール)

const https = require('https');
const options = {
  hostname: 'example.com',
  port: 443,
  path: '/',
  minVersion: 'TLSv1.2',  // TLS 1.2 最低限; TLS 1.3 も許可
};
https.get(options, (res) => console.log(res.statusCode));

注意: minVersion(Node 12以上)は最新のアプローチです。古いsecureProtocol: 'TLSv1_2_method'はTLS 1.2に固定されてTLS 1.3をブロックするため、新しいコードでは使用しないでください。

修正2 — OpenSSLとシステムライブラリを更新する

OpenSSL 1.0.2は2019年12月にサポートが終了しました。1.1.1未満のバージョンでは、現代のサーバーが要求するECDHEおよびAES-GCM暗号スイートが欠落しています。

# 現在のバージョンを確認する
openssl version -a

# Ubuntu/Debian
sudo apt update && sudo apt upgrade openssl libssl-dev

# CentOS/RHEL
sudo yum update openssl

# macOS(Homebrew 経由)
brew upgrade openssl

アップグレード後は、PythonやNode.jsの環境も再インストールまたは再ビルドしてください。どちらもコンパイル時にシステムのOpenSSLにリンクされており、新しいバージョンを自動的には取得しません。

修正3 — 暗号スイートを明示的に指定する

クライアントを制御できる場合は、サーバーが受け入れる暗号を少なくとも1つ明示的にリストアップしてください。

明示的な暗号を使ったcurl

curl --tlsv1.2 \
  --ciphers 'ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384' \
  https://example.com

Python SSLコンテキスト

import ssl
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.ssl_ import create_urllib3_context

CIPHERS = 'ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:AES128-GCM-SHA256'

class CipherAdapter(HTTPAdapter):
    def init_poolmanager(self, *args, **kwargs):
        ctx = create_urllib3_context(ciphers=CIPHERS)
        kwargs['ssl_context'] = ctx
        return super().init_poolmanager(*args, **kwargs)

session = requests.Session()
session.mount('https://', CipherAdapter())
response = session.get('https://example.com')

修正4 — SNIの問題(バーチャルホスティング)

サーバーが1つのIPに複数のドメインをホストしている場合、適切な証明書を選択するためにSNI拡張が必要です。SNIを送信しないクライアントは拒否されます。

# SNIを明示的にテストする
openssl s_client -connect example.com:443 -servername example.com

# SNIなし(古いクライアントをシミュレート)
openssl s_client -connect example.com:443 -noservername

最初のコマンドが成功して2番目が失敗する場合、サーバーはSNIを要求しています。朗報として、Python 2.7.9以上、Node.js 0.12以上、Java 8u161以上はすべてSNIを自動的に送信します。それより古いバージョンを使用している場合はランタイムをアップグレードしてください — SNIサポートを手動でパッチするのは手間に見合いません。

修正5 — 証明書チェーンの問題

サーバー上の中間証明書の欠落は、意外にもよくある原因です。

# 完全な証明書チェーンを確認する
openssl s_client -connect example.com:443 -showcerts 2>/dev/null | \
  openssl x509 -noout -dates

# 有効期限を確認する
openssl s_client -connect example.com:443 2>/dev/null | \
  openssl x509 -noout -enddate

verify error:num=20:unable to get local issuer certificateが表示される場合、サーバーが中間CA証明書を送信していません。これはサーバー側の修正が必要です — 管理者に連絡して、完全なチェーンのインストールを依頼してください。

修正の確認

# ハンドシェイク成功時の出力例:
openssl s_client -connect example.com:443 -tls1_2
# ...
# SSL-Session:
#     Protocol  : TLSv1.2
#     Cipher    : ECDHE-RSA-AES128-GCM-SHA256
# ...
# Verify return code: 0 (ok)    ← これが目標の出力

# またはcurlで確認する
curl -v https://example.com 2>&1 | grep -E 'SSL|TLS|Connected'
# 表示されるはず: * SSL connection using TLSv1.2 / ECDHE-RSA-AES128-GCM-SHA256

Verify return code: 0 (ok)はハンドシェイクが成功し、証明書チェーンが有効であることを意味します。それ以外の場合は、まだ作業が必要です。

ヒント

証明書のデバッグではファイルの整合性確認が伴うことがよくあります。ToolCraftのHash Generatorを使えば、証明書ファイルのSHA-256またはMD5ハッシュをブラウザ上で直接生成できます — 機密性の高い鍵を扱う際に重要な、ファイルのアップロードは一切ありません。

継続的な予防のために、以下の習慣を心がけてください:

  • すべてのHTTPクライアント設定でTLS 1.2を最低限として設定する — 「自動」のままにしないこと
  • CIパイプラインにopenssl s_clientチェックを追加して、ユーザーより先に証明書の期限切れを検知する
  • Pythonでssl.SSLContext.check_hostname = Falseを設定しないこと — ホスト名の検証が完全に無効になる
  • サーバーを管理している場合は、SSL LabsでTLS設定をテストし、Aランクを目指すこと

Related Error Notes