目次
- 通常のTLSとmTLSの違い
- 通常のTLSは「サーバーだけ」が身元を証明する
- mTLSは「クライアントも」証明書で身元を証明する
- プライベートCAの作り方
- 自分のCAを作り、クライアント証明書に署名する
- Nginxでのssl_verify_client設定
- CA証明書を指定し、検証を必須にする
- クライアント側からの接続確認
- 利用例:マイクロサービス・IoT・社内API
- マイクロサービス:サービスメッシュが自動でmTLSを張る
- IoT:デバイス1台ごとに証明書を焼き込む
- 社内API・パートナーAPI:APIキーの上に重ねる
- 証明書の配布と失効の運用
- 秘密鍵は「配らない」、証明書は「短命にする」
- トラブル事例:curlでは通るのにアプリからは400になる
- 症状
- 原因
- 対処
- まとめ
「社内APIをインターネット経由で別拠点のサーバーから呼びたいが、APIキーだけでは心もとない」「取引先から“mTLSで接続してください”と言われ、クライアント証明書の作り方から調べている」「IoTデバイスが数百台あり、1台ごとに認証したい」。こうした場面で登場するのがmTLS(相互TLS認証)です。名前は知っていても、通常のHTTPSと何が違うのか、自分で構築するには何が必要なのかは意外と知られていません。
この記事では、通常のTLSとmTLSの違いをハンドシェイクの流れから説明し、opensslでプライベートCAとクライアント証明書を作る手順、Nginxでssl_verify_clientを使ってクライアント証明書を検証する設定、マイクロサービス・IoT・社内APIでの利用例、そして運用で最も難しい証明書の配布と失効の考え方までを扱います。SSL/TLSの基本とLet's Encryptによるサーバー証明書の取得はSSL/TLS証明書の仕組み入門で解説済みのため、本記事はその上に積み上げる形で進めます。
通常のTLSとmTLSの違い
通常のTLSは「サーバーだけ」が身元を証明する
ブラウザでHTTPSサイトを開くとき、サーバーは証明書を提示し、クライアント(ブラウザ)は「この証明書は信頼できる認証局(CA)が発行したもので、アクセスしているドメインと一致する」ことを検証します。このとき身元を証明しているのはサーバー側だけで、クライアントが誰であるかはTLSの層では一切確認していません。クライアントの認証は、その後のHTTPの層でパスワードやAPIキー、Cookieなどを使って別途行っています。
この方式は不特定多数が使うWebサイトには適していますが、「特定の相手だけが接続してよい」サービス間通信では弱点になります。APIキーは文字列なので、ログや環境変数から漏れれば誰でも使えますし、そもそも接続自体は誰からでも受け付けてしまうため、認証処理の手前まで攻撃者が到達できます。
mTLSは「クライアントも」証明書で身元を証明する
mTLS(mutual TLS、相互TLS)は、TLSハンドシェイクの中でサーバーがクライアントに証明書を要求し、クライアントも自分の証明書を提示して、双方が相手を検証する方式です。流れとしては、サーバーが自身の証明書に続けて「CertificateRequest」を送り、クライアントは自身の証明書と、その証明書に対応する秘密鍵で署名した「CertificateVerify」を返します。サーバーは、クライアント証明書が信頼するCAから発行されていること、署名が正しいこと(秘密鍵を持っている証拠)、有効期限内であることを確認し、いずれかが満たされなければハンドシェイクの段階で接続を拒否します。
| 項目 | 通常のTLS | mTLS |
|---|---|---|
| 証明書を提示する側 | サーバーのみ | サーバーとクライアントの両方 |
| クライアントの認証 | HTTP層(パスワード・APIキー等) | TLS層(証明書+秘密鍵の所持) |
| 認証されない相手 | 接続後、アプリで拒否 | ハンドシェイクで拒否。アプリに到達しない |
| 認証情報の漏えい耐性 | 文字列が漏れれば使われる | 秘密鍵が漏れない限り使えない |
| 証明書の発行元 | 公開CA(Let's Encrypt等) | サーバー側は公開CAでも可、クライアント側は自組織のプライベートCAが一般的 |
| 主な用途 | 公開Webサイト | サービス間通信、IoT、社内・パートナーAPI |
mTLSの本質は「秘密鍵を持っていることの証明」です。秘密鍵はデバイスやサーバーから外に出さず、証明書(公開鍵に署名したもの)だけを配ることで、認証情報を盗まれるリスクをAPIキーより大幅に下げられます。
プライベートCAの作り方
自分のCAを作り、クライアント証明書に署名する
クライアント証明書は、Let's Encryptのような公開CAではなく、自組織で運用するプライベートCAで発行するのが一般的です。「このCAが署名した証明書だけを信頼する」という形にすることで、接続できる相手を自組織が完全に管理できます。まずはopensslで最小構成のCAを作り、サーバー証明書とクライアント証明書を1枚ずつ発行してみます。
- CAの秘密鍵と自己署名のルート証明書を作る(この秘密鍵が最重要。オフライン保管が望ましい)
- サーバーの秘密鍵とCSR(証明書署名要求)を作り、CAで署名する(サーバー証明書はLet's Encryptのものでも構わない)
- クライアントの秘密鍵とCSRを作り、CAで署名する。クライアント用途を示す拡張(
extendedKeyUsage = clientAuth)を付ける - クライアントには「クライアント証明書+秘密鍵」と「サーバーを検証するためのCA証明書」を渡す
# 1. プライベートCA(有効期間10年)
openssl genrsa -out ca.key 4096
openssl req -x509 -new -key ca.key -sha256 -days 3650 \
-subj "/CN=Example Internal CA/O=Example Inc" -out ca.crt
# 2. サーバー証明書(SAN付き)
openssl genrsa -out server.key 2048
openssl req -new -key server.key -subj "/CN=api.internal.example.com" -out server.csr
printf "subjectAltName=DNS:api.internal.example.com\nextendedKeyUsage=serverAuth\n" > server.ext
openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial \
-days 397 -sha256 -extfile server.ext -out server.crt
# 3. クライアント証明書(有効期間は短めに。ここでは90日)
openssl genrsa -out client-batch01.key 2048
openssl req -new -key client-batch01.key -subj "/CN=batch01/OU=batch/O=Example Inc" -out client-batch01.csr
printf "extendedKeyUsage=clientAuth\n" > client.ext
openssl x509 -req -in client-batch01.csr -CA ca.crt -CAkey ca.key -CAcreateserial \
-days 90 -sha256 -extfile client.ext -out client-batch01.crt
# 発行内容の確認
openssl x509 -in client-batch01.crt -noout -subject -dates -ext extendedKeyUsage
実運用では、opensslを手で叩くのではなく、step-ca、cfssl、HashiCorp VaultのPKIエンジン、あるいはクラウドのプライベートCAサービス(AWS Private CA、Google Cloud CA Service)を使うと、発行・更新・失効の管理が楽になります。ただし仕組みは上のコマンドと同じなので、一度手で作っておくと理解が深まります。
Nginxでのssl_verify_client設定
CA証明書を指定し、検証を必須にする
Nginxでクライアント証明書を検証するには、ssl_client_certificateに信頼するCAの証明書を指定し、ssl_verify_client onを設定します。証明書を提示しない、あるいは検証に失敗したクライアントには、Nginxが400番台のエラー(400 No required SSL certificate was sentなど)を返し、バックエンドには一切届きません。
server {
listen 443 ssl;
http2 on;
server_name api.internal.example.com;
# サーバー側の証明書(Let's Encrypt でも自前CAでもよい)
ssl_certificate /etc/nginx/tls/server.crt;
ssl_certificate_key /etc/nginx/tls/server.key;
ssl_protocols TLSv1.2 TLSv1.3;
# クライアント証明書の検証
ssl_client_certificate /etc/nginx/tls/ca.crt; # 信頼するCA(中間CAがあれば連結)
ssl_verify_client on; # on | optional | optional_no_ca
ssl_verify_depth 2; # 中間CAを挟む場合は深さを増やす
# ssl_crl /etc/nginx/tls/ca.crl; # 失効リストを使う場合
location / {
proxy_pass http://127.0.0.1:3000;
# 認証済みクライアントの情報をバックエンドへ渡す
proxy_set_header X-Client-Verify $ssl_client_verify; # SUCCESS / FAILED / NONE
proxy_set_header X-Client-DN $ssl_client_s_dn; # 例: CN=batch01,OU=batch,O=Example Inc
proxy_set_header X-Client-Serial $ssl_client_serial;
}
}
ssl_verify_client optionalにすると、証明書がなくてもハンドシェイクは成功し、$ssl_client_verifyの値で後段が判断できます。「同じホストで、一部のパスだけmTLSを要求する」ときに使い、locationブロックでif ($ssl_client_verify != SUCCESS) { return 403; }のように制御します。バックエンドへ渡すヘッダーは、Nginxを経由せずに直接バックエンドへ到達できる経路があると偽装されるため、バックエンドはNginx(127.0.0.1やプライベートネットワーク)からの接続だけを受け付けるようにしておきます。
クライアント側からの接続確認
# curl: クライアント証明書と秘密鍵、サーバー検証用のCAを指定
curl --cert client-batch01.crt --key client-batch01.key --cacert ca.crt \
https://api.internal.example.com/health
# ハンドシェイクの詳細を見る(CertificateRequest の有無、Acceptable client certificate CA names)
openssl s_client -connect api.internal.example.com:443 \
-cert client-batch01.crt -key client-batch01.key -CAfile ca.crt < /dev/null
# Node.js から呼ぶ場合
# const https = require('node:https'); const fs = require('node:fs');
# const agent = new https.Agent({ cert: fs.readFileSync('client.crt'), key: fs.readFileSync('client.key'), ca: fs.readFileSync('ca.crt') });
# fetch はエージェント非対応なので、undici の Agent か https.request を使う
openssl s_clientの出力に「Acceptable client certificate CA names」としてCAのDNが表示されれば、サーバーはクライアント証明書を要求しており、そのCAで発行された証明書を受け付ける状態です。Nginxの基本設定についてはLinux×Nginx入門を参照してください。
利用例:マイクロサービス・IoT・社内API
マイクロサービス:サービスメッシュが自動でmTLSを張る
Kubernetes上のマイクロサービスでは、IstioやLinkerdといったサービスメッシュを導入すると、各Podに挿入されたサイドカープロキシ同士が自動的にmTLSで通信します。証明書の発行と数時間単位の自動ローテーションもメッシュが担うため、アプリケーションのコードを変えずに「サービス間の通信はすべて暗号化され、相手の身元が保証される」状態を作れます。クラスタ内部だから安全、という前提を置かないゼロトラストの考え方の基盤になっています。マイクロサービスの設計全般はマイクロサービス設計入門、Kubernetesの基礎はKubernetes入門で扱っています。
IoT:デバイス1台ごとに証明書を焼き込む
センサーやゲートウェイなどのIoTデバイスは、パスワードを人が入力できないうえ、台数が多く、物理的に持ち去られるリスクもあります。製造時やセットアップ時にデバイスごとの秘密鍵と証明書を書き込み、mTLSで接続させると、「どの個体からの通信か」を確実に識別でき、盗まれた個体の証明書だけを失効させて締め出せます。AWS IoT CoreやAzure IoT Hubのデバイス認証はまさにこの方式で、証明書のCNにデバイスIDを入れて識別するのが定番です。
社内API・パートナーAPI:APIキーの上に重ねる
拠点間や取引先との間でAPIを呼び合う場合、mTLSを「接続できる相手を限定する層」として使い、その上でAPIキーやJWTによる認可を行う二段構えが実務的です。金融機関系のAPIや、CDN(Cloudflareの「Authenticated Origin Pulls」など)からオリジンへの接続に「CDN以外からの直接アクセスを禁止する」目的でmTLSが使われるのも、この考え方です。クラウドのロードバランサー(AWS ALB、API Gatewayなど)もmTLS終端に対応してきており、Nginxを自前で運用しなくても導入できる選択肢が増えています。
証明書の配布と失効の運用
秘密鍵は「配らない」、証明書は「短命にする」
mTLSの運用で最も重要なのは秘密鍵の扱いです。理想は、クライアント側で鍵ペアを生成し、CSR(公開鍵)だけをCAへ送って署名してもらう流れで、秘密鍵はそのデバイスやサーバーから一度も外に出ません。やむを得ず中央で鍵ペアを作って配る場合も、チャットやメールで送らず、シークレット管理サービスや暗号化した経路で渡し、受け取り後は配布元から削除します。
失効については、CRL(失効リスト)やOCSPという仕組みがありますが、Nginxのssl_crlはファイルを再読み込みしないと反映されず、OCSPも構成が複雑です。現実的には「証明書の有効期間を短くする(数日〜90日)」ことで、失効の必要性そのものを減らすアプローチが主流になっています。有効期間が短ければ、漏えいした証明書の利用可能期間も短く、更新の自動化が強制されるため、更新忘れによる障害も減ります。step-caやVaultはこの短命証明書の自動更新を前提に設計されています。
- クライアント側で鍵ペアとCSRを生成し、CAに署名してもらう(秘密鍵は動かさない)
- 有効期間を短く設定し、期限の1/3〜1/2が経過した時点で自動更新するジョブを組む
- 証明書のシリアル番号と発行先の台帳を持ち、紛失・退役時は該当シリアルをCRLに追加して
nginx -s reloadする - CAの秘密鍵はオフラインまたはHSM/KMSで保護し、日常の発行には中間CAを使う
- 期限切れを監視する。サーバー証明書と同様、クライアント証明書の残日数も監視項目に入れる
トラブル事例:curlでは通るのにアプリからは400になる
症状
取引先から提供されたクライアント証明書を使い、curlでは--certと--keyを指定して正常に応答が返る。ところが本番のバッチ(Node.js)から同じ証明書で接続すると、相手のサーバーが400 The SSL certificate errorを返す。
原因
取引先から届いた証明書ファイルは「クライアント証明書+中間CA証明書」が連結されたPEMだった。curlは連結ファイルをそのまま送るため中間CAを含むチェーンが相手に届いていたが、Node.jsのコードではファイルの1つ目の証明書だけをcertに渡しており、中間CAが送られていなかった。相手サーバーはssl_verify_depthの範囲でルートCAまで辿れず、検証失敗として400を返していた。証明書そのものは正しく、「チェーンを送っていない」ことが原因だった。
対処
Node.jsのcertオプションに、クライアント証明書と中間CA証明書を連結したPEM全体を渡すように修正した(certはチェーン全体を受け付ける)。切り分けにはopenssl s_clientの-certにそれぞれのファイルを指定して結果を比較し、さらに相手側にお願いしてNginxのエラーログ(client SSL certificate verify error: (2:unable to get issuer certificate))を確認してもらったことで確定した。再発防止として、mTLSクライアントの接続テストをopenssl s_clientで行う手順書と、証明書ファイルの中身(openssl storeutl -noout -text -certsで何枚入っているか)を確認するチェック項目を追加した。
まとめ
mTLSは、通常のTLSでサーバーだけが行っていた証明書による身元証明を、クライアント側にも要求する方式です。認証がハンドシェイクの段階で完了するため、正規のクライアント以外はアプリケーションに到達すらできず、秘密鍵が漏れない限り認証情報を盗まれることもありません。構築には、プライベートCAでクライアント証明書を発行し、Nginxのssl_client_certificateとssl_verify_client onで検証する、という2つの要素があれば十分です。運用では、秘密鍵をクライアント側で生成して動かさないこと、証明書を短命にして自動更新すること、失効よりも期限で締め出す設計にすることが要点になります。まずは検証環境でCAを1つ作り、curlとopenssl s_clientで接続の成否を確かめるところから始めてみてください。
サービス間通信のセキュリティ設計やmTLSの導入でお困りの際は、Harmonic Societyのシステム開発・インフラ支援にご相談ください。
Harmonic Society
この記事の内容、自社の業務でも活かせそうですか?
ローカルLLM・AI・クラウドなどの技術導入を、要件整理からPoC・社内展開まで代表エンジニアが伴走します。オンライン対応・全国OK。まずは30分の無料相談から。売り込みはしません。
関連記事
Related / 9 articles
Notes & Insights
- プログラミング
DDoS攻撃の仕組みと対策入門|レイヤー別の防御とCDN・クラウドの活用
DDoS攻撃をボリューム型・プロトコル型・アプリ層に分けて仕組みを解説し、自前サーバーで防げない理由、CloudflareやAWS Shieldの標準防御、オリジンIPの隠し方、レートリミットとBot対策、攻撃を受けたときの初動、費用が跳ね上がるDenial of Walletへの備えまでわかります。
- プログラミング
WAFとは?仕組み・導入パターン・誤検知対策|Webアプリを攻撃から守る実践ガイド
WAFがファイアウォールやIDSと何が違うのか、シグネチャとマネージドルールの仕組み、Cloudflare WAF・AWS WAF・ModSecurityの比較、フォーム送信がブロックされる誤検知の調査と例外設定、ログ監視、WAFが代替できないことまで実践的に解説します。
- プログラミング
セキュリティヘッダー入門|CSP・HSTS・X-Frame-Optionsの設定と効果を実践解説
CSP・HSTS・X-Frame-Options・X-Content-Type-Optionsなど主要セキュリティヘッダーが防ぐ攻撃と、CSPのReport-Onlyからの段階導入、nonce/hash、HSTS preloadの不可逆リスク、Nginx・Next.js・Astroでの設定例、確認方法を解説します。
- プログラミング
クラウドの通信費(Egress)入門|データ転送量課金の仕組みと転送コストを抑える設計
クラウドの「受信無料・送信有料」の原則、AZ間・リージョン間・インターネット向けの単価差、NATゲートウェイ処理料の罠、CDNで転送量を減らす方法、バックアップやログ転送の見落とし、請求書で転送料を特定する手順を解説。想定外の請求を防げます。
- プログラミング
秘密情報をGitに入れない仕組み|.gitignore・git-secrets・履歴から漏れた鍵の削除
APIキーや.envをGitにコミットしてしまう典型経路と、.gitignore・.env.exampleの運用、pre-commitでのgitleaks検知、GitHub secret scanningの活用、漏れた鍵の無効化と履歴書き換え(git filter-repo)の手順を解説。仕組みで再発を防げます。
- プログラミング
開発・ステージング・本番環境の分離設計|環境差分をなくす構成とアクセス制御
開発・ステージング・本番それぞれの目的と、構成をコードで揃える方法、環境別の設定注入、本番データを使わないテストデータ戦略、ステージングの保護(Basic認証・IP制限・noindex)、コストを抑える運用までを解説。環境差分による本番障害を防げます。
- プログラミング
ngrok・Cloudflare Tunnelでローカルを公開|Webhook開発とデモ環境の作り方
NAT内のローカル環境にStripeやLINEのWebhookを届けるトンネリングの仕組みを解説。ngrok・Cloudflare Tunnel・localtunnelの比較、固定ドメインと認証、リクエスト検査、公開時のセキュリティ、自宅サーバー公開への応用までわかります。
- プログラミング
ローカル開発環境のHTTPS化|mkcert・hostsファイル・自己署名証明書の正しい使い方
ローカル開発をHTTPS前提にすべき理由(Secure Cookie・Service Worker・OAuth)と、mkcertでローカルCAを作りhostsで独自ドメインを割り当ててVite・Next.js・Dockerで使う手順を解説。証明書警告を無視する癖の危険も理解できます。
- プログラミング
localhost・0.0.0.0・127.0.0.1の違い|ポートとUnixソケットを理解して「つながらない」を解決
localhost・127.0.0.1・0.0.0.0の意味の違い、Dockerで外から接続できない原因、host.docker.internal、ポート競合の調べ方、Unixソケットの利点と権限、1024未満ポートの制約を解説。「つながらない」を仕組みから解決できます。
Harmonic Society
「読んで終わり」にせず、自社の業務で試してみませんか?
AI・ローカルLLM・クラウドの導入を、要件整理からPoC・社内展開まで代表エンジニアが伴走します。オンライン対応・全国OK・売り込みなし。
無料・30分・オンラインOK|1営業日以内に返信します