はじめに — ブラウザには鍵マークが見えるのに curl は失敗します
新しく発行した証明書をデプロイしました。ブラウザで開いてみると鍵マークは正常で、証明書の情報にも問題はありません。ところがバックエンドからそのエンドポイントを呼ぶと、こう失敗します。
curl -sS https://api.example.com/v1/health
curl: (60) SSL certificate problem: unable to get local issuer certificate
More details here: https://curl.se/docs/sslcerts.html
この時点で最もよくある結論は「curl が最新の CA を知らない」です。そこで ca-certificates を更新し、それでもだめなら -k を付けたりアプリケーションで検証を切ったりします。これは原因を覆い隠す対応であり、たいていサーバー設定が誤ったまま運用に入ることになります。
本当の原因はほぼ常に一つです。サーバーが中間証明書を一緒に送っておらず、ブラウザがそれを自力で埋めて成功していただけです。この記事ではそのメカニズムを正確に説明し、エラーメッセージを見ただけで原因を指し示せるように整理します。
TLS 1.3 ハンドシェイクをメッセージ順に読む
デバッグをするには、どのメッセージがどの順序で行き交うのかを知る必要があります。TLS 1.3 は往復一回で終わります。
クライアント サーバー
| |
|-- ClientHello -------------------------------------> |
| supported_versions: TLS 1.3 |
| key_share: x25519 公開鍵 |
| server_name: api.example.com (SNI, 平文) |
| application_layer_protocol_negotiation: h2, http/1.1
| |
| <------------------------------------ ServerHello -- |
| key_share: x25519 公開鍵 |
| ---- ここから先はすべて暗号化される ---- |
| <------------------------------- EncryptedExtensions |
| <----------------------------- CertificateRequest(*) |
| <------------------------------------- Certificate |
| <------------------------------- CertificateVerify |
| <------------------------------------------ Finished |
| |
|-- Certificate(*) ----------------------------------> |
|-- CertificateVerify(*) ----------------------------> |
|-- Finished ----------------------------------------> |
|-- Application Data (最初のHTTPリクエスト) ---------> |
| |
(*)はmTLSを要求するときだけ登場
ここから、運用に直接影響する事実が三つ出てきます。
一つめ、ServerHello 以降のすべてのメッセージが暗号化されます。TLS 1.2 ではパケットキャプチャだけでサーバー証明書を取り出せましたが、TLS 1.3 では不可能です。Wireshark で証明書が見えないからといって、サーバーが証明書を送っていないわけではありません。証明書を確認するには必ずクライアント側で、つまり openssl s_client のようなツールで確認しなければなりません。
二つめ、ClientHello の SNI は依然として平文です。ほかがすべて暗号化されても、接続先のホスト名は露出します。これが Encrypted Client Hello が登場した理由でもあります。デバッグの観点ではむしろ有用です。パケットキャプチャで SNI だけはいつでも読めます。
三つめ、クライアントが Finished と最初のリクエストを続けて送ります。サーバーがクライアント証明書を検証する前に、クライアントはすでにハンドシェイクが終わったと判断します。そのため mTLS の認証失敗は、ハンドシェイクエラーではなく最初のリクエストを送った直後の接続切断として現れます。この違いが mTLS のデバッグを難しくする核心です。
SNI がなければサーバーは適当な証明書を差し出します
一つの IP とポートに数百のサイトがぶら下がっているのが、いまどきの基本構成です。サーバーは TCP 接続だけでは、どの証明書を差し出せばよいのか分かりません。その情報を ClientHello の SNI 拡張が伝えます。
SNI なしで接続すると、サーバーは既定の仮想ホストの証明書を返します。
# SNI を外して IP で直接接続
openssl s_client -connect 203.0.113.40:443 -noservername </dev/null 2>/dev/null \
| openssl x509 -noout -subject
subject=CN = default.vhost.example.net
# SNI を明示すれば正しい証明書が出てくる
openssl s_client -connect 203.0.113.40:443 -servername api.example.com </dev/null 2>/dev/null \
| openssl x509 -noout -subject
subject=CN = api.example.com
openssl s_client で最も頻繁に起きるミスがここにあります。-connect だけ使って -servername を外すと SNI が送信されず、実際のサービスとは違う証明書を見て見当違いの結論を出すことになります。最近の OpenSSL は -connect のホスト名を SNI として自動設定しますが、IP で接続する場合や旧バージョンを使う場合はそうなりません。-servername は常に明示する習慣が安全です。
SNI を送らないクライアントは実際に存在します。かなり古い Java クライアント、一部の組み込み機器、そして一部の監視エージェントがそうです。こうしたクライアントだけが証明書エラーを出す状況なら、原因は証明書ではなく SNI の未送信です。サーバー側の既定の仮想ホストを何にするかが、そのまま対応策になります。
キャプチャで SNI を確認する方法も知っておくとよいでしょう。
sudo tcpdump -nn -i any -A 'tcp port 443 and tcp[((tcp[12:1] & 0xf0) >> 2)] = 0x16' | head -40
チェーン検証と中間証明書の欠落 — ブラウザと curl が分かれる地点
検証は末端の証明書から始まり、発行者をたどって上っていって、ローカルの信頼ストアにあるルートに到達すれば成功します。サーバーの責任は、ルートを除く中間証明書をすべて一緒に送ることです。
正常なチェーンはこう見えます。
openssl s_client -connect api.example.com:443 -servername api.example.com </dev/null 2>/dev/null \
| sed -n '/Certificate chain/,/---/p'
Certificate chain
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
---
中間証明書が欠けたサーバーはこう出ます。
Certificate chain
0 s:CN = api.example.com
i:C = US, O = Let's Encrypt, CN = R11
---
...
Verify return code: 20 (unable to get local issuer certificate)
0番だけがあって1番がありません。クライアントは R11 という発行者を信頼ストアから探さなければならないのに、信頼ストアにはルートである ISRG Root X1 だけがあり、中間証明書の R11 はありません。だから鎖が途切れます。
ではなぜブラウザは成功するのでしょうか。ここが核心です。
末端の証明書の中には、発行者の証明書をダウンロードできる URL が入っています。
openssl s_client -connect api.example.com:443 -servername api.example.com </dev/null 2>/dev/null \
| openssl x509 -noout -text | grep -A3 'Authority Information Access'
Authority Information Access:
OCSP - URI:http://r11.o.lencr.org
CA Issuers - URI:http://r11.i.lencr.org/
Chrome、Safari、Edge は検証中に発行者を見つけられないと、この CA Issuers の URL へ HTTP リクエストを送って中間証明書を自分でダウンロードします。これを AIA fetching あるいは AIA chasing と呼びます。Firefox は少し違うやり方で、既知の中間証明書をあらかじめ内蔵し、一度見た中間証明書をキャッシュしておきます。
一方 OpenSSL は既定では AIA をたどりません。curl、wget、Python の requests、Go、Node のほとんどがこれに該当します。Java も既定ではたどらず、com.sun.security.enableAIAcaIssuers プロパティを有効にする必要があります。
したがって次の命題が成り立ちます。ブラウザでは通るのに curl で失敗するなら、間違っているのはクライアントではなくサーバーです。ブラウザはサーバーのミスを肩代わりして埋めているにすぎません。しかも AIA fetching はネットワークリクエストなので、最初の接続のたびに数十から数百ミリ秒を余分に使います。ユーザーの体感遅延にもつながります。
確認は二つのコマンドで終わります。
# サーバーが送るチェーンだけで検証できるか確認 (信頼ストアはシステム既定)
openssl s_client -connect api.example.com:443 -servername api.example.com \
-verify_return_error </dev/null 2>&1 | tail -3
# チェーンをファイルに落として個数を確認
openssl s_client -connect api.example.com:443 -servername api.example.com -showcerts </dev/null 2>/dev/null \
| grep -c 'BEGIN CERTIFICATE'
Verify return code: 0 (ok)
2
直す方法はサーバー設定です。nginx の ssl_certificate には、末端の証明書と中間証明書を順に連結したファイル(fullchain)を指定しなければなりません。Apache は最近のバージョンなら SSLCertificateFile に fullchain を入れればよく、旧バージョンは SSLCertificateChainFile を別途指定します。ロードバランサーなら、証明書登録画面のチェーン項目を空のままにしていないか確認します。
エラーメッセージ辞典
メッセージを正確に読むだけで、原因のほとんどが決まります。
| エラーメッセージ | 実際の原因 | 確認コマンド | 対処 |
|---|---|---|---|
| unable to get local issuer certificate | サーバーが中間証明書を送っていない、またはローカルにルートがない | s_client のチェーン数を確認、信頼ストアの存在を確認 | サーバーに fullchain を配備、または ca-certificates を導入 |
| certificate has expired | notAfter 経過、またはクライアントの時計がずれている | openssl x509 -noout -dates、date を確認 | 更新、または NTP 同期 |
| certificate is not yet valid | notBefore より前、事実上クライアントの時計の問題 | date、timedatectl | 時刻同期 |
| hostname mismatch, no alternative subject name | 接続した名前が証明書の SAN 一覧にない | openssl x509 -noout -ext subjectAltName | SAN に名前を追加、または正しい SNI で接続 |
| self signed certificate in certificate chain | 社内 CA か傍受プロキシが途中にいる | チェーン最上位の発行者を確認 | 社内ルートを信頼ストアに登録 |
| self signed certificate | 末端の証明書そのものが自己署名 | issuer と subject が同じか確認 | 正式な証明書を発行 |
| tlsv1 alert protocol version | クライアントとサーバーの最小プロトコルバージョンが重ならない | s_client に -tls1_2、-tls1_3 をそれぞれ指定して比較 | サーバー設定の調整、またはクライアントの更新 |
| sslv3 alert handshake failure | 共通の暗号スイートがない、または mTLS でクライアント証明書未提出 | openssl ciphers、s_client に -cert と -key を指定 | 暗号スイートを揃える、またはクライアント証明書を提示 |
| certificate required (alert 116) | TLS 1.3 の mTLS でクライアントが空の証明書を送った | サーバーログ、s_client に -cert と -key を指定 | クライアント証明書を設定 |
二つの誤解を正しておきます。
hostname mismatch について、現代のクライアントは CN フィールドをまったく見ず、subjectAltName だけを検査します。CN にドメインを入れてあるから大丈夫だという判断は、2017年以降は誤りです。Chrome は58版から CN フォールバックを削除し、OpenSSL と Go も同様です。必ず SAN を確認しなければなりません。
openssl s_client -connect api.example.com:443 -servername api.example.com </dev/null 2>/dev/null \
| openssl x509 -noout -ext subjectAltName
X509v3 Subject Alternative Name:
DNS:api.example.com, DNS:api-internal.example.com
certificate has expired もよく誤診されます。サーバー証明書ではなくクライアントの時計が狂っている場合がかなり多いのです。時計のない組み込み機器、長く止まってから起動した VM、ホストの時刻とずれたコンテナが代表例です。有効期限と現在時刻を一緒に確認する習慣が時間を節約してくれます。
openssl s_client -connect api.example.com:443 -servername api.example.com </dev/null 2>/dev/null \
| openssl x509 -noout -dates
date -u
notBefore=Jun 2 08:14:31 2026 GMT
notAfter=Aug 31 08:14:30 2026 GMT
Sun Jul 26 05:11:20 UTC 2026
openssl s_client で確認する順序
一度にすべてを見るコマンドを覚えておくと楽です。
HOST=api.example.com
PORT=443
openssl s_client -connect "${HOST}:${PORT}" -servername "${HOST}" \
-showcerts -status -alpn h2,http/1.1 </dev/null 2>&1 | head -60
主な出力はこう読みます。
CONNECTED(00000003)
depth=2 C = US, O = Internet Security Research Group, CN = ISRG Root X1
verify return:1
depth=1 C = US, O = Let's Encrypt, CN = R11
verify return:1
depth=0 CN = api.example.com
verify return:1
---
Certificate chain
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
---
ALPN protocol: h2
OCSP response: no response sent
---
New, TLSv1.3, Cipher is TLS_AES_128_GCM_SHA256
Verify return code: 0 (ok)
Verify return code: 0 (ok) が最終判定です。depth が高いほうから降りてくる verify の行は、どの段階で途切れたのかを教えてくれます。
プロトコル別に通るかどうかを分けて見るのも有用です。
openssl s_client -connect api.example.com:443 -servername api.example.com -tls1_3 </dev/null 2>&1 | grep -E 'New,|Cipher'
openssl s_client -connect api.example.com:443 -servername api.example.com -tls1_2 </dev/null 2>&1 | grep -E 'New,|Cipher'
New, TLSv1.3, Cipher is TLS_AES_128_GCM_SHA256
New, TLSv1.2, Cipher is ECDHE-RSA-AES128-GCM-SHA256
メールやデータベースのように STARTTLS を使うプロトコルはオプションを付ける必要があります。
openssl s_client -connect smtp.example.com:587 -starttls smtp -servername smtp.example.com </dev/null
openssl s_client -connect db.example.com:5432 -starttls postgres </dev/null
curl で見るときは、次の組み合わせが最も情報量が多いです。
curl -v --resolve api.example.com:443:203.0.113.40 https://api.example.com/v1/health 2>&1 | grep -E '^\*'
* Server certificate:
* subject: CN=api.example.com
* start date: Jun 2 08:14:31 2026 GMT
* expire date: Aug 31 08:14:30 2026 GMT
* subjectAltName: host "api.example.com" matched cert's "api.example.com"
* issuer: C=US; O=Let's Encrypt; CN=R11
* SSL certificate verify ok.
--resolve は DNS を迂回して特定の IP に接続しつつ、SNI と Host ヘッダーは元の名前のまま保ちます。ロードバランサーの背後にある個々のノードを一台ずつ検証するときに必須です。
信頼ストア、コンテナ、そして mTLS の失敗地点
検証が失敗するもう一つの軸は、クライアント側の信頼ストアです。ディストリビューションごとにパスと更新コマンドが違います。
# Debian、Ubuntu
ls -l /etc/ssl/certs/ca-certificates.crt
cp corp-root.crt /usr/local/share/ca-certificates/corp-root.crt
update-ca-certificates
# RHEL、Rocky、Fedora
ls -l /etc/pki/tls/certs/ca-bundle.crt
cp corp-root.crt /etc/pki/ca-trust/source/anchors/
update-ca-trust extract
# Alpine
apk add --no-cache ca-certificates
cp corp-root.crt /usr/local/share/ca-certificates/
update-ca-certificates
コンテナで最も頻繁に起きる事故は、最小イメージに信頼ストアがまったくない場合です。Go のバイナリを scratch や alpine の上に載せると、こう失敗します。
x509: certificate signed by unknown authority
証明書が間違っているのではなく、比較すべきルートがイメージの中に一つもないのです。Alpine なら ca-certificates パッケージを導入し、静的バイナリなら distroless の base イメージを使うか、マルチステージビルドで証明書の束をコピーします。
FROM alpine:3.20 AS certs
RUN apk add --no-cache ca-certificates
FROM scratch
COPY /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY app /app
ENTRYPOINT ["/app"]
ランタイムごとに信頼ストアを指す環境変数が違うという点も覚えておく価値があります。
export SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt # OpenSSL, Go, curl
export REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt # Python の requests
export NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/corp-root.crt # Node
# Java は別のキーストアを使う
keytool -importcert -alias corp-root -file corp-root.crt \
-keystore "$JAVA_HOME/lib/security/cacerts" -storepass changeit -noprompt
self signed certificate in certificate chain のエラーが社内ネットワークでだけ出るなら、原因はたいてい傍受プロキシです。会社が TLS 検査装置を使っていると、すべての証明書が社内 CA で再署名されて出てきます。チェーン最上位の発行者を見ればすぐに分かります。
mTLS は失敗地点が二倍に増えます。確認の順序はこうです。
# サーバーがクライアント証明書を要求するか、そしてどの CA を受け入れるか確認
openssl s_client -connect mtls.example.com:8443 -servername mtls.example.com </dev/null 2>&1 \
| sed -n '/Acceptable client certificate CA names/,/^---/p'
Acceptable client certificate CA names
C = KR, O = Example Internal CA, CN = Example Issuing CA
Client Certificate Types: RSA sign, ECDSA sign
この一覧が決定的です。自分のクライアント証明書の発行者がここになければ、その証明書では絶対に通りません。発行者を確認します。
openssl x509 -in client.crt -noout -issuer -subject -dates
openssl verify -CAfile ca.crt client.crt
issuer=C = KR, O = Example Internal CA, CN = Example Issuing CA
subject=CN = payments-service
notBefore=Jul 1 00:00:00 2026 GMT
notAfter=Jul 1 00:00:00 2027 GMT
client.crt: OK
ではクライアント証明書を提示して接続します。
openssl s_client -connect mtls.example.com:8443 -servername mtls.example.com \
-cert client.crt -key client.key -CAfile ca.crt </dev/null 2>&1 | tail -5
curl -sS --cert client.crt --key client.key --cacert ca.crt \
https://mtls.example.com:8443/v1/health
先に述べた TLS 1.3 の特性がここで表に出ます。クライアント証明書が拒否されてもハンドシェイクは成功したように見え、エラーは最初のリクエストを送ったあとに現れます。
curl: (56) OpenSSL SSL_read: error:0A000412:SSL routines::sslv3 alert bad certificate, errno 0
クライアントのログだけを見るとリクエスト送信中に切れたように見えるので、mTLS の失敗は必ずサーバーログと合わせて見なければなりません。拡張鍵用途が合っているかも見落としがちな項目です。サーバー証明書には serverAuth、クライアント証明書には clientAuth が必要です。
openssl x509 -in client.crt -noout -ext extendedKeyUsage
X509v3 Extended Key Usage:
TLS Web Client Authentication
おわりに — クライアントを直す前にチェーンを数えてください
証明書の問題の判断順序は短くまとめられます。
まず openssl s_client に -servername を必ず付けて接続し、出力の Certificate chain に何個出るかを数えます。末端の証明書一つしか出てこなければ、その瞬間に原因は確定です。サーバーが中間証明書を送っておらず、ブラウザが AIA でそれを埋めてくれていたのです。クライアントに手を入れる代わりに、サーバーへ fullchain を配備すれば終わりです。
チェーンが揃っているのに失敗するなら、そのときこそ信頼ストアを見る番です。コンテナに ca-certificates があるか、社内 CA が登録されているか、ランタイム別の環境変数が合っているかを確認します。
そして検証を切るという選択肢は、最後まで残しておくべきです。-k や verify=False は問題を解決せず見えなくするだけであり、その状態で配備されれば傍受攻撃にそのままさらされます。ほとんどの証明書エラーは、サーバー設定の一行で正確に解決します。
현재 단락 (1/201)
新しく発行した証明書をデプロイしました。ブラウザで開いてみると鍵マークは正常で、証明書の情報にも問題はありません。ところがバックエンドからそのエンドポイントを呼ぶと、こう失敗します。