エラーの概要
Node.jsアプリがHTTPSエンドポイントにアクセスすると、次のエラーで落ちます:
Error: UNABLE_TO_VERIFY_LEAF_SIGNATURE
at TLSSocket.onConnectEnd (_tls_wrap.js:1495:19)
at Object.onceWrapper (events.js:422:26)
at TLSSocket.emit (events.js:327:22)
深夜2時です。Chromeではそのエンドポイントは問題なく開けるし、手元のPCでcurlしても文句ひとつ言わないのに、Node.jsだけが頑なに接続を拒否します。証明書は有効です——目で確認できています。いったい何が起きているのでしょうか?
このエラーの本当の意味
これは証明書の有効期限切れではありません。自己署名証明書でもありません。問題は、Node.jsがサーバーの証明書からNode.jsが認識できるルートCAまでの信頼チェーンを構築できないことです。
十中八九、原因はシンプルです。サーバーがTLSハンドシェイク時にリーフ証明書を送信するものの、中間CA証明書を省略しているのです。中間証明書がなければチェーンは成立しません。チェーンがなければ接続できません。
ブラウザはこれをうまく処理します——Authority Information Access(AIA)を使って欠落している中間証明書を自動的に取得し、ローカルにキャッシュします。Node.jsにはその機能がありません。毎回、完全なチェーンが最初から提供される必要があります。
再現と診断
ステップ1 — サーバーのチェーンが壊れていないか確認する
サーバーに対してOpenSSLを直接実行します:
openssl s_client -connect yourdomain.com:443 -showcerts
正常なチェーンには少なくとも2つの証明書が表示されます:
Certificate chain
0 s:CN=yourdomain.com
i:CN=Some Intermediate CA
1 s:CN=Some Intermediate CA
i:CN=Root CA
壊れたチェーンには1つしか表示されません:
Certificate chain
0 s:CN=yourdomain.com
i:CN=Some Intermediate CA
リーフ証明書だけです。中間証明書がありません。これが原因です。
ステップ2 — Node.jsで失敗することを確認する
node -e "require('https').get('https://yourdomain.com', r => console.log(r.statusCode)).on('error', e => console.error(e.message))"
出力にUNABLE_TO_VERIFY_LEAF_SIGNATUREが表示されれば、原因が確定です。
解決策
解決策1 — サーバーを修正する(正しい対処法)
これはNode.jsのバグではなく、サーバーの設定ミスです。根本から修正しましょう。完全な証明書チェーンを送信するようサーバーを設定します。
Nginxの場合、証明書を1つのファイルにまとめ、ssl_certificateでそのファイルを指定します:
# Concatenate leaf + intermediate
cat your_domain.crt intermediate.crt > fullchain.pem
# In nginx.conf
ssl_certificate /etc/nginx/ssl/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/your_domain.key;
Apacheの場合:
SSLCertificateFile /etc/ssl/certs/your_domain.crt
SSLCertificateChainFile /etc/ssl/certs/intermediate.crt
Node.js HTTPSサーバーの場合:
const https = require('https');
const fs = require('fs');
https.createServer({
key: fs.readFileSync('server.key'),
cert: fs.readFileSync('server.crt'),
ca: fs.readFileSync('intermediate.crt') // add this line
}, app).listen(443);
サーバーをリロードして、openssl s_clientを再実行してください。チェーンの出力にリーフ証明書と中間証明書の両方が表示されるはずです。
解決策2 — Node.jsクライアント側でCA証明書を指定する
サーバーを管理できない場合はどうでしょうか?チェーンが壊れたサードパーティAPIを呼び出しているケースもあります。その場合、Node.jsに信頼するCA証明書を明示的に指定します:
const https = require('https');
const fs = require('fs');
const agent = new https.Agent({
ca: fs.readFileSync('/path/to/intermediate-or-root-ca.crt')
});
https.get({
hostname: 'yourdomain.com',
path: '/',
agent: agent
}, (res) => {
console.log(res.statusCode);
});
axiosを使っている場合も同様です。オプション名が違うだけです:
const axios = require('axios');
const https = require('https');
const fs = require('fs');
const agent = new https.Agent({
ca: fs.readFileSync('/path/to/ca-bundle.crt')
});
const response = await axios.get('https://yourdomain.com/api', {
httpsAgent: agent
});
これは恒久的な修正ではなく、あくまで回避策です。サーバーチームに働きかけて、チェーンを正しく解決してもらいましょう。
解決策3 — システムのCAバンドルを更新する(Linuxサーバーの場合)
セットアップしたばかりのLinuxサーバーは、発行元のルートCAが含まれていない古いCAバンドルを持っていることがあります。手軽なアップデートで大抵解決します:
# Debian/Ubuntu
sudo apt-get update && sudo apt-get install -y ca-certificates
sudo update-ca-certificates
# RHEL/CentOS
sudo yum update ca-certificates
update-ca-trust
Node.jsは起動時にシステムのCAバンドルを読み込みます。更新後はコードを変更する必要はありません——プロセスを再起動するだけです。
やってはいけないこと
Stack Overflowにあふれているこの方法には手を出してはいけません:
// DO NOT DO THIS IN PRODUCTION
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
// Same problem:
const agent = new https.Agent({ rejectUnauthorized: false });
証明書の検証を無効にしても問題は解決しません。TLS検証が完全に無効化され、アプリが中間者攻撃に対して無防備な状態になります。ローカルの使い捨てスクリプトならまだしも、本番環境では深刻なセキュリティインシデントになりかねません。
修正の確認
すべてが正常に動作していることを確認する3つのコマンドです:
# 1. Check the server sends the full chain
openssl s_client -connect yourdomain.com:443 -showcerts 2>&1 | grep -E '(subject|issuer|Certificate chain)'
# 2. Verify the chain validates against the system CA store
openssl verify -CAfile /etc/ssl/certs/ca-certificates.crt /dev/null console.log('OK:', r.statusCode)).on('error', e => console.error('FAIL:', e.message))"
OK: 200が表示されるはずです。あるいはそのエンドポイントが通常返すステータスコードが表示されるでしょう——大事なのは、エラーが消えていることです。
より詳細な監査を行うには、SSL Labsでサーバーをチェックしてみてください。欠落している中間証明書を明示的に指摘し、ブックマークしておく価値のあるチェーンの可視化も提供してくれます。
学んだこと
- **ブラウザは嘘をつく。**ChromeやFirefoxは欠落している中間証明書を静かに取得してキャッシュします。ページは問題なく表示されているのに、Node.js、
curl、そしてすべてのサーバー間通信クライアントが同じエンドポイントを拒否する、ということが起こり得ます。TLSのテストは常にopenssl s_clientで行いましょう——ブラウザのタブではありません。 - **証明書の更新が最大のトリガー。**誰かがリーフ証明書を更新してコピーしたものの、中間証明書の再バンドルを忘れたのです。デプロイ手順書に
openssl s_clientによるチェーン確認を追加してください。5秒で完了し、毎回これを検知できます。 - **クライアント側の回避策は匂いがする。**あるエンドポイントと通信するすべてのサービスに
caのオーバーライドを注入しているなら、それは技術的負債が蓄積しているサインです。チェーンはサーバー側で対処すべきです。チケットを起票しましょう。 - **コンテナのベースイメージは古くなる。**1年前に
node:16からビルドしたDockerイメージ上のNode.jsアプリは、新しいルート証明書を信頼しない可能性があります。DockerfileにRUN apt-get install -y ca-certificatesを追加してリビルドしましょう——深夜2時に本番環境でこのバグを追いかけることにならないように。

