本文へスキップ
ecdsa.com
← すべての記事

API のための相互 TLS(mTLS):クライアント証明書の実践

使うのはプライベート CA 1 つ、クライアント証明書 5 枚、サーバー 4 つです。以下の設定はすべて 2026年10月20日に実行したもので、エラーメッセージはすべて nginx 1.30、Envoy 1.39、Go 1.27、Node.js 24 が実際に出力したものです。

要点:相互 TLS(mTLS)では、サーバーが CertificateRequest を送信し、クライアントは自分の証明書チェーンと、ハンドシェイクに対する署名で応答して、秘密鍵を保持していることを証明します。サーバーはそのチェーンを自分自身のトラストアンカーであるプライベート CA に照らして検証し、続いて証明書を ID に対応付けます。理想的なのは、SPIFFE ID のような URI SAN 1 つだけを ID にすることです。押さえるべき点は 4 つです。クライアントが中間証明書を送信する(またはサーバーが中間証明書を持っている)必要があること、サーバーはクライアント証明書を発行する CA だけを信頼すること、認可は認証の後に行うこと(有効な証明書があっても、それは許可ではありません)、そして証明書を短命にして自動で更新することです。クライアントに公開 CA の TLS 証明書を使ってはいけません。公開 CA の階層では clientAuth の廃止が進んでいます。

実験環境は、バージョンを固定したイメージで動かしました。debian:forky-20261005-slim(OpenSSL 3.6.5、nginx 1.30.4、curl 8.22.0)、envoyproxy/envoy:v1.39.4、golang:1.27.3-trixie、node:24.22.0-bookworm-slim です。鍵と証明書はすべて、この実験環境のために生成したものです。

mTLS が適している場面

mTLS は、鍵を保持するマシン、ワークロード、デバイスを、HTTP リクエストが解析される前に、接続のレベルで認証します。そのため、次のような用途に適しています。

  • プラットフォーム内部のサービス間通信。サービスメッシュやサイドカー(Istio、Linkerd、Consul、単体の Envoy)が、すべてのワークロードに証明書を発行し、ローテーションします。
  • パートナー向け API と B2B API。パートナーごとに自社の CA から証明書を発行します。FAPI 2.0 のセキュリティプロファイルは、mTLS をクライアント認証の方式としても(private_key_jwt と並んで)、アクセストークンを紐付ける方式としても(DPoP と並んで)認めています。
  • デバイスとエージェント。鍵をハードウェア(TPM やセキュアエレメント)に保管し、接続のたびにその鍵を提示できます。

ブラウザーにはあまり向いていません。ユーザーには証明書の選択ダイアログが表示されますし、手前で TLS を終端する CDN やロードバランサーがある場合は、それ自体がクライアント証明書に対応していなければなりません。接続がエンドツーエンドでない場合は、DPoP や HTTP メッセージ署名のようなリクエスト単位の証明のほうが、中継を経ても機能しやすくなります(このページの最後を参照してください)。

ハンドシェイクに加わるもの

TLS 1.3(2026年7月に RFC 8446 を置き換えた RFC 9846)では、サーバーの CertificateRequest は EncryptedExtensions の直後に送られます。このメッセージには signature_algorithms を含めなければならず、受け入れる CA の名前を certificate_authorities に列挙することもできます(§4.4.2)。クライアントは Certificate、CertificateVerify(トランスクリプトに対するクライアントの署名)、Finished で応答します。クライアントが何も送らなかった場合、サーバーは「自らの裁量で、クライアント認証なしでハンドシェイクを続行するか、certificate_required アラートでハンドシェイクを中止してもよい(MAY)」とされています(§4.5.1.3)。

サーバーが何を要求しているかは、openssl s_client で確認できます。以下は、後述の nginx サーバーに対して実行した結果です。

openssl s_client -connect 127.0.0.1:8443 -servername api.internal.test -CAfile root.pem(抜粋)
Acceptable client certificate CA names
O=Example, CN=Example Internal Root
Requested Signature Algorithms: id-ml-dsa-65:id-ml-dsa-87:id-ml-dsa-44:ECDSA+SHA256:ECDSA+SHA384:ECDSA+SHA512:ed25519:ed448:...

実務上、重要な帰結が 2 つあります。1 つ目に、クライアントはこの CA リストを使って証明書を選びます。なかには、リストにある名前までチェーンがつながらない証明書の送信を拒むクライアントもあります(後述のとおり、Go がそうです)。2 つ目に、TLS 1.3 ではクライアントはサーバーの Finished を受信した後で自分の証明書を送信するため、クライアントから見ると、サーバーが証明書を判定する前にハンドシェイクは完了しています。そのため、拒否はクライアントが最初に読み取りを行った時点でアラートとして表面化します。curl がこれをハンドシェイクのエラーではなく (56) ... SSL_read として報告するのはこのためです。

クライアント証明書用のプライベート CA

クライアント証明書は、公開 CA ではなく、自分で運用する CA(またはマネージドのプライベート CA として借りる CA)から発行します。公開 CA には「これは自社の課金ワーカーです」ということを検証できませんし、公開 CA の TLS 階層ではクライアント認証の廃止が進んでいます。Chrome Root Program は 2027年3月15日以降、サブスクライバー証明書に serverAuth のみを求め、Let's Encrypt は 2026年7月8日に clientAuth を含む証明書の発行を終了しました(X.509 証明書のフィールドを参照してください)。

実験環境の階層は、ルート、pathlen:0 を持つ発行 CA、api.internal.test のサーバー証明書、そして SPIFFE 形式の URI SAN を ID とするクライアント証明書で構成されています。

ext.cnf(抜粋):発行 CA とクライアント証明書のプロファイル
[issuing]
basicConstraints = critical, CA:TRUE, pathlen:0
keyUsage = critical, keyCertSign, cRLSign
extendedKeyUsage = serverAuth, clientAuth
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid
[worker7]
basicConstraints = critical, CA:FALSE
keyUsage = critical, digitalSignature
extendedKeyUsage = clientAuth
subjectAltName = URI:spiffe://example.test/ns/prod/sa/worker-7
authorityKeyIdentifier = keyid
pki.sh(抜粋):有効期間 1 日のクライアント証明書とそのチェーンファイルを発行
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out worker-7.key
openssl req -new -key worker-7.key -subj "/O=Example/CN=worker-7" -out worker-7.csr
openssl x509 -req -in worker-7.csr -CA issuing.pem -CAkey issuing.key -extfile ext.cnf -extensions worker7 -days 1 -out worker-7.pem
cat worker-7.pem issuing.pem > worker-7-chain.pem

URI SAN を使うと、型があり曖昧さのない ID になります。SPIFFE の X.509-SVID は URI SAN をちょうど 1 つ持つことを求め、それより多く持つ証明書はバリデーターが「拒否しなければならない(MUST)」と定めています。SPIRE が発行する完全な SVID では、EKU に serverAuth も設定されます。実験環境では worker-7 のほかに、billing(同じ CA、異なる SPIFFE ID)、intruder(同じ名前と ID だが、無関係なルートが署名)、expired(2026年9月1日に有効だった証明書)、そしてルートの 2 階層下にある 2 つ目の発行 CA から worker-9 を発行しました。

nginx

/etc/nginx/conf.d/mtls.conf(nginx 1.30.4)
server {
    listen 8443 ssl;
    server_name api.internal.test;
    error_log /var/log/nginx/mtls.log info;   # client certificate errors are logged at "info"
    ssl_certificate     /w/pki/server-chain.pem;
    ssl_certificate_key /w/pki/server.key;

    ssl_client_certificate /w/pki/root.pem;   # trust anchor for client certificates
    ssl_verify_client      on;
    ssl_verify_depth       2;

    location / {
        default_type text/plain;
        return 200 "verify=$ssl_client_verify subject=$ssl_client_s_dn issuer=$ssl_client_i_dn\n";
    }
}

各ケースを curl --cert ... --key ... で実行した結果と、そのときのログです。

curl の結果と mtls.log(タイムスタンプとクライアントアドレスは削除)
--- no client certificate
<head><title>400 No required SSL certificate was sent</title></head>
--- worker-7, leaf only
<head><title>400 The SSL certificate error</title></head>
--- worker-7 with its intermediate
verify=SUCCESS subject=CN=worker-7,O=Example issuer=CN=Example Issuing CA 1,O=Example
--- certificate from another CA
<head><title>400 The SSL certificate error</title></head>
--- expired certificate
<head><title>400 The SSL certificate error</title></head>

[info] client sent no required SSL certificate while reading client request headers
[info] client SSL certificate verify error: (21:unable to verify the first certificate) while reading client request headers
[info] client SSL certificate verify error: (21:unable to verify the first certificate) while reading client request headers
[info] client SSL certificate verify error: (10:certificate has expired) while reading client request headers

注目すべき点は次のとおりです。

  • nginx はハンドシェイクを完了させたうえで、HTTP 400 で応答します(No required SSL certificate was sent)。内部的には、これらは非標準のコード 496(証明書なし)と 495(検証失敗)で、error_page を使えば独自のレスポンスに置き換えられます。
  • 理由はエラーログにしか出力されず、しかもレベル info でしか出力されません。デフォルトの error レベルでは何も表示されません。
  • リーフ証明書だけでは失敗します。サーバーが信頼しているのはルートなので、中間証明書がなければ検証パスを構築できず、エラー 21 になります。クライアントが leaf + intermediate を送信する(curl の --cert にはチェーンファイルを指定できます)か、サーバーが中間証明書を自分の CA ファイルに追加するかのどちらかが必要です。
  • ssl_verify_depth は中間証明書の数を数えます。デフォルトは 1 です。深さ 1 で再実行したところ、worker-7(中間証明書 1 つ)は引き続き成功し、worker-9(中間証明書 2 つ)は (20:unable to get local issuer certificate) で失敗し、深さ 2 では成功しました。
  • ssl_client_certificate は CA リストをクライアントに送信します。ssl_trusted_certificate は同じ種類のファイルに照らして検証しますが、その内容をクライアントに通知しません(nginx のドキュメント)。クライアントが証明書を選ぶためにこのヒントを必要とする場合は、前者を使ってください。

ID を後段に渡すには、proxy_set_header X-Client-Cert $ssl_client_escaped_cert; で URL エンコードされた PEM を転送します。$ssl_client_verify の値は SUCCESS、FAILED:reason、NONE のいずれかです。ssl_verify_client optional を使う場合は、プロキシする前に $ssl_client_verify を自分で確認してください。

Envoy

Envoy は、ハンドシェイク中に ID を照合し、その ID を x-forwarded-client-cert(XFCC)でアプリケーションに渡すこともできます。

envoy.yaml(抜粋):SAN マッチャーと XFCC を使う mTLS リスナー
transport_socket:
  name: envoy.transport_sockets.tls
  typed_config:
    "@type": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.DownstreamTlsContext
    require_client_certificate: true
    common_tls_context:
      tls_params: { tls_minimum_protocol_version: TLSv1_3 }
      tls_certificates:
      - certificate_chain: { filename: /w/pki/server-chain.pem }
        private_key: { filename: /w/pki/server.key }
      validation_context:
        trusted_ca: { filename: /w/pki/root.pem }
        match_typed_subject_alt_names:
        - san_type: URI
          matcher: { exact: "spiffe://example.test/ns/prod/sa/worker-7" }
# in the http_connection_manager of the same listener:
forward_client_cert_details: SANITIZE_SET
set_current_client_cert_details: { uri: true }

実験環境のバックエンドは 2 つ目の Envoy リスナーで、受け取った XFCC ヘッダーをそのまま返します。以下は、Envoy イメージに含まれる curl 7.81.0 での結果と、Envoy のリスナー統計です。

curl -i の結果と /stats(Envoy 1.39.4)
--- worker-7
HTTP/1.1 200 OK
x-backend-saw-xfcc: Hash=048c219f601a877b19c77fc8834750cdc6b1394b9080a50fa8eaeaf6f3793bd0;URI=spiffe://example.test/ns/prod/sa/worker-7
--- worker-7 sending its own x-forwarded-client-cert
HTTP/1.1 200 OK
x-backend-saw-xfcc: Hash=048c219f601a877b19c77fc8834750cdc6b1394b9080a50fa8eaeaf6f3793bd0;URI=spiffe://example.test/ns/prod/sa/worker-7
--- billing (valid certificate, SAN not allowed)
curl: (56) OpenSSL SSL_read: error:0A000416:SSL routines::sslv3 alert certificate unknown, errno 0
--- no client certificate
curl: (56) OpenSSL SSL_read: error:0A00045C:SSL routines::tlsv13 alert certificate required, errno 0
--- certificate from another CA
curl: (56) OpenSSL SSL_read: error:0A000418:SSL routines::tlsv1 alert unknown ca, errno 0

listener.127.0.0.1_10443.ssl.fail_verify_error: 1
listener.127.0.0.1_10443.ssl.fail_verify_no_cert: 1
listener.127.0.0.1_10443.ssl.fail_verify_san: 1
listener.127.0.0.1_10443.ssl.handshake: 2

重要なのは 2 つ目のリクエストです。クライアントは admin を名乗る独自の XFCC ヘッダーを送信しましたが、SANITIZE_SET がそれを、プロキシが検証した内容で置き換えました。クライアントの ID をヘッダーで転送するプロキシは、受信したヘッダーを必ず削除しなければなりません。また、アプリケーションはそのヘッダーをプロキシからのみ受け入れなければなりません。失敗はそれぞれ、アラート(certificate required、unknown ca、SAN の不一致では certificate unknown)と、個別のカウンターに対応しています。本番環境で最初に確認すべきなのは、このカウンターです。

Go

検証は、ClientCAs プールと組み合わせた tls.RequireAndVerifyClientCert が行います。SPIFFE ID による認可は、通常のハンドラーのコードで実装します。

main.go(抜粋):サーバー側(Go 1.27.3)
var allowed = map[string]string{"spiffe://example.test/ns/prod/sa/worker-7": "/jobs"}

func handler(w http.ResponseWriter, r *http.Request) {
	// With RequireAndVerifyClientCert, VerifiedChains[0][0] is the verified client leaf.
	leaf := r.TLS.VerifiedChains[0][0]
	if len(leaf.URIs) != 1 {
		http.Error(w, "client certificate must carry exactly one URI SAN", http.StatusForbidden)
		return
	}
	id := leaf.URIs[0].String()
	if allowed[id] != r.URL.Path {
		http.Error(w, id+" may not call "+r.URL.Path, http.StatusForbidden)
		return
	}
	fmt.Fprintf(w, "hello %s, cert valid until %s\n", id, leaf.NotAfter.Format(time.RFC3339))
}

func server(ln net.Listener) {
	cert, err := tls.LoadX509KeyPair(pki+"server-chain.pem", pki+"server.key")
	if err != nil {
		log.Fatal(err)
	}
	srv := &http.Server{
		Handler: http.HandlerFunc(handler),
		TLSConfig: &tls.Config{
			Certificates: []tls.Certificate{cert},
			ClientAuth:   tls.RequireAndVerifyClientCert,
			ClientCAs:    pool("root.pem"),
			MinVersion:   tls.VersionTLS13,
		},
		ErrorLog: log.New(os.Stdout, "server log: ", 0),
	}
	srv.ServeTLS(ln, "", "")
}

同じプログラムのクライアント側は、tls.Config.Certificates を設定し、各証明書で /jobs を呼び出します。追加のケースが 1 つあり、そこでは GetClientCertificate を使って証明書を無条件に送信します。

go run .(ポート番号は PORT に置換)
server log: http: TLS handshake error from 127.0.0.1:PORT: tls: client didn't provide a certificate
no client certificate        client error: Get "https://127.0.0.1:PORT/jobs": remote error: tls: certificate required
worker-7                     200 hello spiffe://example.test/ns/prod/sa/worker-7, cert valid until 2026-10-21T15:17:06Z
billing                      403 spiffe://example.test/ns/prod/sa/billing may not call /jobs
server log: http: TLS handshake error from 127.0.0.1:PORT: tls: client didn't provide a certificate
certificate from another CA  client error: Get "https://127.0.0.1:PORT/jobs": remote error: tls: certificate required
server log: http: TLS handshake error from 127.0.0.1:PORT: tls: failed to verify certificate: x509: certificate signed by unknown authority
same, sent anyway            client error: Get "https://127.0.0.1:PORT/jobs": remote error: tls: unknown certificate authority
server log: http: TLS handshake error from 127.0.0.1:PORT: tls: failed to verify certificate: x509: certificate has expired or is not yet valid: current time 2026-10-20T15:18:44Z is after 2026-09-02T00:00:00Z
expired certificate          client error: Get "https://127.0.0.1:PORT/jobs": remote error: tls: expired certificate

Go のクライアントは、intruder の証明書をまったく送信しませんでした。そのチェーンに含まれるどの証明書も、サーバーの certificate_authorities リストにある CA から発行されていなかったため、空の Certificate メッセージで応答し、サーバー側には「client didn't provide a certificate」と記録されました。正しく設定した Go クライアントが certificate required を報告した場合は、何よりも先に、そのチェーンの発行者とサーバーの CA リストを比較してください。強制的に送信すると、この証明書は unknown certificate authority で失敗しました。billing の例は、認証(証明書は有効)と認可(403)が別物であることを示しています。

Node.js

mtls.mjs(抜粋):サーバーのオプションと認可(Node.js 24.22.0)
function authorize(req, res) {
  // With rejectUnauthorized: false the handshake succeeds for any certificate (or none),
  // so this check is the only thing standing between the request and the handler.
  if (!req.socket.authorized) {
    res.writeHead(401).end(`client certificate rejected: ${req.socket.authorizationError}\n`);
    return;
  }
  const cert = req.socket.getPeerCertificate();
  // subjectaltname is a string such as "URI:spiffe://..." — parse it, do not substring-match it.
  const uris = (cert.subjectaltname ?? "").split(", ").filter((n) => n.startsWith("URI:")).map((n) => n.slice(4));
  const id = uris.length === 1 ? uris[0] : null;
  if (!id || ALLOWED.get(id) !== req.url) {
    res.writeHead(403).end(`${id ?? "no single URI SAN"} may not call ${req.url}\n`);
    return;
  }
  res.end(`hello ${id}, serial ${cert.serialNumber}\n`);
}

const serverOptions = (rejectUnauthorized) => ({
  key: pki("server.key"),
  cert: pki("server-chain.pem"),
  ca: pki("root.pem"), // trust anchor for client certificates
  requestCert: true, // send CertificateRequest
  rejectUnauthorized, // true: drop connections whose client certificate does not verify
  minVersion: "TLSv1.3",
});

実験環境では、同じ 5 つの呼び出しを、rejectUnauthorized: true のサーバーと false のサーバーに対して実行しました。

node mtls.mjs(OpenSSL のエラー発生箇所は短縮)
node v24.22.0, OpenSSL 3.5.8
### rejectUnauthorized: true
  server tlsClientError: ERR_SSL_PEER_DID_NOT_RETURN_A_CERTIFICATE
no client certificate        client error: ERR_SSL_TLSV13_ALERT_CERTIFICATE_REQUIRED: ...:tlsv13 alert certificate required:...:SSL alert number 116
worker-7                     200 hello spiffe://example.test/ns/prod/sa/worker-7, serial 7D2333CCB4C609E16E5592C959C71250AD4703BA
billing                      403 spiffe://example.test/ns/prod/sa/billing may not call /jobs
  server tlsClientError: ECONNRESET
certificate from another CA  client error: ECONNRESET: socket hang up
  server tlsClientError: ECONNRESET
expired certificate          client error: ECONNRESET: socket hang up
### rejectUnauthorized: false
no client certificate        401 client certificate rejected: UNABLE_TO_GET_ISSUER_CERT
worker-7                     200 hello spiffe://example.test/ns/prod/sa/worker-7, serial 7D2333CCB4C609E16E5592C959C71250AD4703BA
billing                      403 spiffe://example.test/ns/prod/sa/billing may not call /jobs
certificate from another CA  401 client certificate rejected: UNABLE_TO_VERIFY_LEAF_SIGNATURE
expired certificate          401 client certificate rejected: CERT_HAS_EXPIRED

rejectUnauthorized: true の場合、Node は問題のある証明書を、ソケットを閉じることで拒否します。アラートも理由もどちら側にも残らず、ECONNRESET が返るだけです。証明書がない場合は OpenSSL がそれより前の段階で検出し、正規の certificate required アラートを返します(サーバー側では peer did not return a certificate)。false の場合はすべての接続が受け入れられ、socket.authorizationError に理由が入ります。このモードが安全なのは、すべてのリクエストの処理経路で最初に socket.authorized を確認する場合だけです。本番環境では true のままにし、理由を知る必要があるときは、確認処理を入れたうえで診断用ポートで false を使ってください。

クライアント証明書の発行とローテーション

  • 短い有効期間と自動更新。サービス用のクライアント証明書の有効期間は数時間から数日にすべきで、更新は人ではなくエージェントが行うべきです。SPIFFE ID には SPIRE、Kubernetes では cert-manager を使い、ほかに step-ca、HashiCorp Vault の PKI エンジン、クラウドのプライベート CA も使えます。有効期間 1 日の証明書を失効させる必要はほとんどありませんが、必要になった場合、nginx は ssl_crl で CRL を確認できます。
  • すべての段階で新旧を重ねる。有効期間の約 3 分の 2 が過ぎた時点で更新し、クライアントが再起動せずに新しい証明書を読み込めるようにします(Envoy と SPIRE はこのために SDS API を使います。Go では GetClientCertificate を使って現在のファイルを読み込めます)。
  • CA のローテーション。まず新しいルートをすべてのサーバーの信頼ファイルに追加し、次にそのルートから発行を始め、古いルートから発行された最後の証明書が期限切れになった時点で古いルートを削除します。サーバーはバンドルを信頼するので、両方のルートを同時に有効にしておけます。
  • 用途ごとに 1 つの CA。ある API のクライアント証明書を発行する CA で、別のシステム向けの証明書も発行すべきではありません。そうすると、そのシステムのサーバーもこれらの証明書を受け入れてしまいます。Envoy の SAN マッチャーや独自の認可マップは、第 2 の防御線です。

よくある間違い

  • クライアントの検証にシステムのトラストストアを使う。ssl_client_certificate /etc/ssl/certs/ca-certificates.crt は、あらゆる公開 CA が発行したあらゆる証明書を受け入れますが、公開 CA の証明書は自社のクライアントについて何も示しません。
  • 認証だけして認可しない。どの ID が何を呼び出せるかを定めるルールがなければ、billing の例で示したとおり、自社の CA が発行した有効な証明書はすべて通ってしまいます。
  • 名前を緩く照合する。解析した URI SAN を完全一致で比較してください。サブジェクトの文字列から CN=worker を検索したり、攻撃者が選んだ値を接頭辞で照合したりしてはいけません。
  • 送信元を問わずクライアントの ID を示すヘッダーを信頼する。このヘッダーを設定してよいのは mTLS を終端したプロキシだけで、そのプロキシは受信したヘッダーを削除しなければなりません(Envoy では SANITIZE_SET、nginx では proxy_set_header による上書き)。
  • clientAuth のないクライアント証明書。証明書に EKU があり、そこに clientAuth が含まれていない場合、OpenSSL ベースのサーバーはその証明書を拒否します。実験環境の serverAuth のみのサーバー証明書をクライアント証明書として提示したところ、nginx は (26:unsuitable certificate purpose)、つまり OpenSSL のエラー 26 をログに記録しました。
  • サーバーの手前で TLS を終端するロードバランサー。証明書の検証は、最初の TLS エンドポイントで行われます。それがロードバランサーであれば、ロードバランサーが mTLS を実行して ID を転送するか、TCP を手を加えずにそのまま通過させなければなりません。

OAuth では、RFC 8705 が 2 つの点で mTLS を利用しています。クライアントは証明書を使ってトークンエンドポイントに対して認証し(tls_client_auth)、アクセストークンをその証明書に紐付けることもできます(cnf の x5t#S256、つまり SHA-256 サムプリント)。そのため、盗まれたトークンは鍵がなければ役に立ちません。mTLS がエンドツーエンドで届かない場合は、DPoP が HTTP 層で同様の紐付けを実現し、HTTP メッセージ署名は個々のリクエストに署名します。

出典