mTLS(相互TLS)完全ガイド|マイクロサービス・ゼロトラスト・サービスメッシュ

(更新: 2026年6月21日 ) mTLS 相互TLS マイクロサービス ゼロトラスト サービスメッシュ SPIFFE Envoy Node.js セキュリティ
結論
  • mTLS(相互TLS)は、マイクロサービス間で「通信相手が許可されたワークロードであること」をTransport層で証明するゼロトラストの基盤だ。
  • 実装の定石は、SPIFFE ID + 短期証明書、受信側の requestCert: true + CA検証、Envoy/nginx なら require_client_certificate / ssl_verify_client on
  • 小規模ならOpenSSLとNode.jsで足りるが、サービス数が増えたら SPIRE または Istio/Linkerd で自動ローテーションに移行する。
  • ブラウザ向けの多層防御(CSP・HSTS等)はmTLSとは別層——外部はHTTPヘッダー、内部はmTLSで役割分担する。
  • TLS 1.3の性能最適化(0-RTT等)はmTLS通信でも設定方針を検討する。

mTLS(相互TLS)とは

TLS(Transport Layer Security) は、HTTP 通信を暗号化し、サーバー証明書で「接続先が本物のサーバーであること」をクライアントが検証する仕組みです。ECサイトやAPIの HTTPS は、この一方向認証(サーバー→クライアントへの証明)が標準です。

mTLS(mutual TLS / 相互TLS) は、クライアント側も X.509 証明書 を提示し、サーバー(またはプロキシ)が クライアントの身元を検証する 双方向認証です。日本語のロングテールキーワード「mTLS マイクロサービス」「相互TLS 設定」で検索される実装ニーズの中心は、Kubernetes 上の Pod 間通信VPC 内の east-west トラフィック を、ネットワーク境界(ファイアウォール・セキュリティグループ)だけに頼らず保護することです。

通常TLSとmTLSの違い

観点 TLS(HTTPS) / mTLS
認証の方向 TLS: クライアントがサーバーを認証 / mTLS: サーバーとクライアントが相互に認証
証明書の提示者 TLS: サーバーのみ / mTLS: サーバー + クライアント
主な用途 TLS: ブラウザ→Web/API / mTLS: サービス→サービス、B2B API、ゼロトラスト
クライアント側 TLS: ブラウザ(OSのルートCA) / mTLS: ワークロード(内部CA・SPIFFE)
失敗時の挙動 TLS: 警告画面 / mTLS: 接続拒否(401/403ではなくTLSハンドシェイ失敗)
主体識別 TLS: ドメイン名 / mTLS: CN・SAN・SPIFFE ID(サービスアカウント単位)

TLS ハンドシェイの流れ(mTLS)を簡略化すると次のとおりです。

sequenceDiagram
  participant C as Client Pod
  participant S as Server Pod
  C->>S: ClientHello
  S->>C: ServerHello + サーバー証明書
  C->>C: サーバー証明書をCAで検証
  S->>C: CertificateRequest(クライアント証明書を要求)
  C->>S: クライアント証明書 + 署名
  S->>S: クライアント証明書を内部CAで検証
  C->>S: Finished(暗号化通信開始)
TLSハンドシェイ失敗はアプリログに残らないことが多い

mTLS 検証に失敗すると TCP/TLS 層で接続が切れる ため、Express の req すら届きません。openssl s_client、Envoy/Istio のアクセスログ、nginx の error.logSSL_verify 関連)で切り分けます。

TLS 1.3 と mTLS の関係

マイクロサービス間通信では TLS 1.2 以上(実務では TLS 1.3)が推奨です。TLS 1.3 はハンドシェイが短く、暗号スイートが AEAD に統一されています。ただし 0-RTT(early data) はリプレイ攻撃の受け口になり得るため、内部 API でも非冪等な POST/PUT を early data で受け付けない設計が必要です。詳細は TLS 1.3 0-RTT(Early Data)のリスク を参照してください。

設定項目推奨(mTLS east-west)理由
min TLS versionTLS 1.2 以上(可能なら 1.3)弱い暗号の排除
0-RTT / early data原則 無効リプレイ耐性
証明書寿命24h〜90d + 自動更新侵害時の影響窓縮小
CRL/OCSP大規模なら検討緊急失効の伝播

ゼロトラストとマイクロサービス

ゼロトラスト(Zero Trust) の原則は「ネットワークの内側=安全」という暗黙の信頼を捨て、すべての通信主体を明示的に認証・認可する ことです。Google の BeyondCorp や NIST SP 800-207 が参照モデルとして知られています。

マイクロサービスアーキテクチャでは次の理由で mTLS が実務的に重要になります。

リスクmTLSなしの典型mTLSによる緩和
Pod侵害後の横展開侵害Podから同一クラスタ内のDB APIへ平文HTTP許可証明書を持たないPodからの接続をTLS層で拒否
なりすましcurl http://payment-svc:8080 で内部API直呼びクライアント証明書なしではハンドシェイ不可
通信の盗聴・改ざんクラスタ内スニッフィングTLS 1.2/1.3 による暗号化
監査・トレーサビリティIPだけでは「どのサービスか」不明確証明書の CN/SAN/SPIFFE ID で主体を識別

アプリケーション層の JWTOAuth2 は依然として必要です。mTLS は 「そのリクエストが許可されたサービスから来ているか」 を Transport 層で保証し、JWT は 「その操作をユーザー/サービスアカウントが実行してよいか」 をアプリ層で判断します——補完関係 にあり、JWT だけでは「侵害された別サービスからの内部呼び出し」を防ぎきれません。

導入ロードマップ

1

Phase 1: ステージングで OpenSSL 内部CA + Node.js requestCert による PoC を構築し、openssl s_client で相互接続を検証する

2

Phase 2: cert-manager または SPIRE で短期証明書の自動発行・ローテーションを導入し、overlap 期間と監視アラートを設定する

3

Phase 3: Envoy サイドカーまたは Istio/Linkerd で STRICT mTLS を有効化し、アプリは平文 HTTP のまま透過的に保護する

4

Phase 4: AuthorizationPolicy / SPIRE Entry 等で SPIFFE ID 単位の L7 認可を追加し、NetworkPolicy と組み合わせる

5

Phase 5: 失効ドリル・ローテーション障害シミュレーション・TLS 1.3 0-RTT 方針の定期レビューを運用 Runbook に組み込む

SPIFFE / SPIRE 概要

手動 OpenSSL CA は学習に最適ですが、本番の数十〜数百 Pod では SPIFFE(Secure Production Identity Framework for Everyone) とその参照実装 SPIRE が de facto 標準に近づいています。

SPIFFE ID とは

SPIFFE ID はワークロード(Pod・VM・プロセス)に付与される URI 形式の一意識別子 です。

spiffe://trust-domain.example/ns/production/sa/orders-api
         └─ Trust Domain ─┘ └─ パス(K8sなら ns + SA) ─┘

X.509 証明書の Subject Alternative Name (SAN)URI:spiffe://... が入り、Envoy・Istio・Linkerd はこの ID を principal として AuthorizationPolicy に使います。

SPIRE のアーキテクチャ

コンポーネント役割
SPIRE ServerTrust Domain の CA。エントリ登録・証明書署名
SPIRE Agent各ノードに1つ。ワークロードに SVID(SPIFFE Verifiable Identity Document)を配布
Workload APIUnix socket 経由で短期証明書・秘密鍵を Pod に提供
Registration Entry「この Selector(K8s SA 等)のワークロードにこの SPIFFE ID を付与」を定義
flowchart TB
  subgraph control [コントロールプレーン]
    SS[SPIRE Server]
    SE[Registration Entries]
  end
  subgraph node [Kubernetes Node]
    SA[SPIRE Agent]
    P1[Pod: orders-api]
    P2[Pod: checkout-bff]
  end
  SE --> SS
  SS --> SA
  SA -->|Workload API| P1
  SA -->|Workload API| P2
  P1 -->|mTLS SVID| P2

SPIRE Registration Entry 例

# spire-entries/orders-api.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: spire-entry-orders-api
  namespace: spire
data:
  entry.hcl: |
    entry {
      spiffe_id = "spiffe://prod.example/ns/production/sa/orders-api"
      parent_id = "spiffe://prod.example/spire/agent/k8s_psat/production/node-1"
      selectors {
        type = "k8s"
        value = "sa:production:orders-api"
      }
      ttl = "3600"
    }

Pod 内のアプリは Workload API から証明書を取得します。Node.js では @peculiar/x509 や SPIRE の公式 SDK、Envoy では SDS(Secret Discovery Service) 経由が一般的です。

Istio / Linkerd との関係

Istio は内部で istiod が各ワークロードに証明書を配布し、SPIFFE 互換の spiffe://cluster.local/ns/{ns}/sa/{sa} 形式の ID を使います。Linkerd も同様に自動 mTLS を提供します。SPIRE を直接使うか、メッシュに任せるかは 既存メッシュの有無非 K8s ワークロード(VM・バッチ)の混在 で決めます。

Trust Domain は組織境界

Trust Domain を prod.examplestaging.example で分離し、本番 CA がステージングに流用されないよう ルート CA も分離 してください。マルチクラスタでは フェデレーション で Trust Bundle を交換する設計になります。

サービスメッシュの基礎

サービスメッシュ(Service Mesh) は、各 Pod に サイドカープロキシ(Envoy 等) を注入し、サービス間通信の mTLS・リトライ・メトリクスを データプレーン で透過的に処理するアーキテクチャです。

コントロールプレーンとデータプレーン

役割代表例
コントロールプレーン証明書発行、ルーティング設定、ポリシー配布istiod、Linkerd control plane
データプレーン実際のプロキシ。TLS 終端、mTLS、L7 ルーティングEnvoy、Linkerd-proxy

アプリは http://orders-api:8080 のように 平文 HTTP で呼び出してよく、サイドカーが outbound を mTLS にアップグレードします。これにより アプリコードを変えず east-west 暗号化が可能です。

主要メッシュ製品の mTLS 比較

製品 mTLS の特徴
Istio PeerAuthentication STRICT。istiod が自動証明書。AuthorizationPolicy で SPIFFE principal 認可
Linkerd デフォルト mTLS オン。軽量プロキシ。設定がシンプル
Consul Connect Consul CA + Envoy サイドカー。マルチクラウド VM 向け
Cilium eBPF ベース。WireGuard 暗号化 + mTLS オプション

メッシュなし vs メッシュあり

観点ネイティブ mTLS(アプリ/Envoy 直)サービスメッシュ
導入コスト低(サービス数が少ない場合)中〜高(CRD・サイドカー注入)
ローテーション自前(cert-manager + reloder 等)自動(istiod / linkerd)
可観測性自前メトリクス組み込み(Golden metrics)
L7 認可アプリ or ゲートウェイで実装AuthorizationPolicy 等
向いている規模PoC、数サービス数十〜数百サービス

証明書設計:内部CAと属性

本番の mTLS では 自社内部CA または SPIFFE/SPIRE によるワークロードIDが標準です。

X.509 証明書に含めるべき属性

  • Subject Alternative Name (SAN)DNS:orders-api.internal, URI:spiffe://prod.example/ns/production/sa/orders-api
  • Common Name (CN) — レガシー互換。SAN を優先し、CN もサービス名に揃える
  • 有効期間 — 内部CAは 90日以下 の短期 + 自動ローテーションが推奨
  • Key Usage / Extended Key Usage — サーバー認証 serverAuth、クライアント認証 clientAuth

証明書ローテーション — 設計と運用

証明書ローテーションは mTLS 運用で 最も障害を起こしやすい 領域です。計画的な overlap と監視なしに短期化すると、更新漏れで全サービス間通信が止まります。

ローテーション戦略

方式概要向いているケース
固定期限 + 手動更新825日証明書を期限前に差し替えPoC のみ
短期 + 自動更新cert-manager / SPIRE / Istio が 24h〜90d で更新本番標準
ダブル CA overlap新旧 CA を並行信頼し、全クライアント更新後に旧 CA 廃止CA ルート更新時
緊急失効侵害 SA の Entry 削除、CRL/OCSP、証明書 Secret 削除インシデント対応

overlap タイムライン(90日証明書の例)

Day 0   ── 新証明書発行(有効90日)
Day 75  ── renewBefore: 15日前に次期証明書を先行発行
Day 75-90 ── 新旧両方有効(クライアント・サーバーがどちらでも接続可)
Day 90  ── 旧証明書失効(更新漏れPodは接続失敗 → アラート)

cert-manager による自動ローテーション

apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: internal-ca-issuer
spec:
  ca:
    secretName: internal-ca-key-pair
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: orders-api-tls
  namespace: production
spec:
  secretName: orders-api-tls
  duration: 2160h
  renewBefore: 360h
  commonName: orders-api
  dnsNames:
    - orders-api
    - orders-api.production.svc.cluster.local
  uris:
    - spiffe://prod.example/ns/production/sa/orders-api
  usages:
    - server auth
    - client auth
  issuerRef:
    name: internal-ca-issuer
    kind: ClusterIssuer

Pod では Secret を volumeMount します。Node.js / nginx は SIGHUP または reloder で再読込が必要な場合があります。Envoy SDS なら ホットリロード が可能です。

監視すべきメトリクス

  • TLS handshake failures — Envoy tls.inspection、nginx ssl_verify エラー
  • 証明書期限x509_cert_not_after Prometheus exporter、Certificate Ready=False
  • ローテーション成功率 — cert-manager CertificateRenewal イベント
  • クライアント SPIFFE ID 別 RPS — 想定外の主体からの試行

ローテーション障害ドリル

本番前に必ず実施するチェックリストです。

  1. 意図的に renewBefore を短縮 し、ステージングで自動更新を観察
  2. 旧 Secret を削除 し、Pod が新証明書で再起動・再接続できるか確認
  3. CA 証明書をローテーション し、trust bundle 配布の lag を測定
  4. 1 サービスのみ更新停止 し、アラートが鳴るか検証

OpenSSL:CA・サーバー・クライアント証明書の生成

以下は ローカル開発・ステージング用 の完全な手順です。本番では HSM・Vault PKI・cert-manager 等で鍵を保護してください。

ディレクトリ構成

mkdir -p mtls-demo/{ca,server,client,configs}
cd mtls-demo

1. 内部CA(ルート)の作成

cat > configs/ca-openssl.cnf << 'EOF'
[ req ]
default_bits       = 4096
prompt             = no
default_md         = sha256
distinguished_name = dn
x509_extensions    = v3_ca

[ dn ]
CN = MyOrg Internal mTLS Root CA
O  = MyOrg
C  = JP

[ v3_ca ]
basicConstraints = critical, CA:TRUE, pathlen:1
keyUsage = critical, keyCertSign, cRLSign
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid:always,issuer
EOF

openssl genrsa -out ca/ca.key.pem 4096
chmod 400 ca/ca.key.pem

openssl req -x509 -new -nodes \
  -key ca/ca.key.pem \
  -days 3650 \
  -out ca/ca.cert.pem \
  -config configs/ca-openssl.cnf

openssl x509 -in ca/ca.cert.pem -text -noout | head -20

2. サーバー証明書(orders-api 用)

cat > configs/server-openssl.cnf << 'EOF'
[ req ]
default_bits       = 2048
prompt             = no
default_md         = sha256
distinguished_name = dn
req_extensions     = req_ext

[ dn ]
CN = orders-api
O  = MyOrg
C  = JP

[ req_ext ]
subjectAltName = @alt_names
extendedKeyUsage = serverAuth, clientAuth

[ alt_names ]
DNS.1 = orders-api
DNS.2 = orders-api.internal
DNS.3 = localhost
URI.1 = spiffe://dev.example/ns/default/sa/orders-api
IP.1  = 127.0.0.1

[ v3_server ]
basicConstraints = CA:FALSE
keyUsage = critical, digitalSignature, keyEncipherment
extendedKeyUsage = serverAuth
subjectAltName = @alt_names
EOF

openssl genrsa -out server/orders-api.key.pem 2048

openssl req -new \
  -key server/orders-api.key.pem \
  -out server/orders-api.csr.pem \
  -config configs/server-openssl.cnf

openssl x509 -req \
  -in server/orders-api.csr.pem \
  -CA ca/ca.cert.pem \
  -CAkey ca/ca.key.pem \
  -CAcreateserial \
  -out server/orders-api.cert.pem \
  -days 90 \
  -extfile configs/server-openssl.cnf \
  -extensions v3_server

cat server/orders-api.cert.pem ca/ca.cert.pem > server/orders-api.chain.pem

3. クライアント証明書(checkout-bff 用)

cat > configs/client-openssl.cnf << 'EOF'
[ req ]
default_bits       = 2048
prompt             = no
default_md         = sha256
distinguished_name = dn
req_extensions     = req_ext

[ dn ]
CN = checkout-bff
O  = MyOrg
C  = JP

[ req_ext ]
subjectAltName = @alt_names
extendedKeyUsage = clientAuth

[ alt_names ]
URI.1 = spiffe://dev.example/ns/default/sa/checkout-bff

[ v3_client ]
basicConstraints = CA:FALSE
keyUsage = critical, digitalSignature
extendedKeyUsage = clientAuth
subjectAltName = @alt_names
EOF

openssl genrsa -out client/checkout-bff.key.pem 2048

openssl req -new \
  -key client/checkout-bff.key.pem \
  -out client/checkout-bff.csr.pem \
  -config configs/client-openssl.cnf

openssl x509 -req \
  -in client/checkout-bff.csr.pem \
  -CA ca/ca.cert.pem \
  -CAkey ca/ca.key.pem \
  -CAcreateserial \
  -out client/checkout-bff.cert.pem \
  -days 90 \
  -extfile configs/client-openssl.cnf \
  -extensions v3_client

4. 接続テスト(openssl s_client)

openssl s_client -connect localhost:8443 \
  -CAfile ca/ca.cert.pem \
  -cert client/checkout-bff.cert.pem \
  -key client/checkout-bff.key.pem \
  -servername orders-api \
  -verify_return_error

openssl s_client -connect localhost:8443 \
  -CAfile ca/ca.cert.pem \
  -servername orders-api
自己署名CAをブラウザに信頼させない

内部CAの ca.cert.pem を開発者PCの「信頼されたルート証明書」に入れるのは 開発用の例外 です。本番ユーザー向けHTTPSは公開CA(Let’s Encrypt等)を使い、内部mTLS用CAとは 鍵もファイルも分離 してください。

Node.js:https.createServer でクライアント証明書検証

Node.js で mTLS を終端する最小構成です。許可されたクライアントCNのallowlist まで含めた完全例を示します。

server.mjs(orders-api)

import https from 'node:https';
import fs from 'node:fs';

const PORT = 8443;
const ALLOWED_CLIENT_CN = new Set(['checkout-bff', 'admin-gateway']);
const ALLOWED_SPIFFE = new Set([
  'spiffe://dev.example/ns/default/sa/checkout-bff',
  'spiffe://dev.example/ns/default/sa/admin-gateway',
]);

const tlsOptions = {
  key: fs.readFileSync('server/orders-api.key.pem'),
  cert: fs.readFileSync('server/orders-api.cert.pem'),
  requestCert: true,
  rejectUnauthorized: true,
  ca: fs.readFileSync('ca/ca.cert.pem'),
  minVersion: 'TLSv1.2',
};

function extractSpiffeUri(peerCert) {
  const san = peerCert.subjectaltname || '';
  const match = san.match(/URI:spiffe:\/\/[^\s,]+/);
  return match ? match[0].replace('URI:', '') : null;
}

const server = https.createServer(tlsOptions, (req, res) => {
  const socket = req.socket;
  const authorized = socket.authorized;
  const peerCert = socket.getPeerCertificate();

  if (!authorized) {
    res.writeHead(401, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify({
      error: 'client certificate required or invalid',
      authorizationError: socket.authorizationError,
    }));
    return;
  }

  const clientCn = peerCert?.subject?.CN;
  const spiffeUri = extractSpiffeUri(peerCert);

  const cnOk = ALLOWED_CLIENT_CN.has(clientCn);
  const spiffeOk = spiffeUri && ALLOWED_SPIFFE.has(spiffeUri);

  if (!cnOk && !spiffeOk) {
    res.writeHead(403, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify({
      error: 'client identity not allowed',
      cn: clientCn,
      spiffe: spiffeUri,
    }));
    return;
  }

  if (req.url === '/health') {
    res.writeHead(200, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify({ status: 'ok', client: clientCn, spiffe: spiffeUri }));
    return;
  }

  if (req.method === 'GET' && req.url?.startsWith('/orders/')) {
    const id = req.url.split('/').pop();
    res.writeHead(200, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify({ orderId: id, servedBy: 'orders-api', caller: spiffeUri || clientCn }));
    return;
  }

  res.writeHead(404);
  res.end('not found');
});

server.listen(PORT, () => {
  console.log(`orders-api mTLS listening on https://localhost:${PORT}`);
});

client.mjs(checkout-bff からの呼び出し)

import https from 'node:https';
import fs from 'node:fs';

const options = {
  hostname: 'localhost',
  port: 8443,
  path: '/orders/ord_123',
  method: 'GET',
  servername: 'orders-api',
  key: fs.readFileSync('client/checkout-bff.key.pem'),
  cert: fs.readFileSync('client/checkout-bff.cert.pem'),
  ca: fs.readFileSync('ca/ca.cert.pem'),
  rejectUnauthorized: true,
};

const req = https.request(options, (res) => {
  let body = '';
  res.on('data', (chunk) => { body += chunk; });
  res.on('end', () => {
    console.log('status', res.statusCode);
    console.log('body', body);
  });
});

req.on('error', (err) => console.error('request failed', err.message));
req.end();
node server.mjs
node client.mjs
Express との組み合わせ

Express では https.createServer(options, app) とします。req.socket.getPeerCertificate()TLS終端後 にのみ有効 — リバースプロキシ後段の平文HTTPでは X-Forwarded-Client-Cert 等のヘッダー設計が別途必要です。

Envoy:Downstream / Upstream mTLS 設定

Envoy は Istio・Linkerd のデータプレーンとしても使われ、スタンドアロン でもサイドカー・ゲートウェイとして mTLS を終端できます。

Downstream mTLS(クライアント証明書を要求)

# envoy-downstream-mtls.yaml
static_resources:
  listeners:
    - name: orders_api_listener
      address:
        socket_address:
          address: 0.0.0.0
          port_value: 8443
      filter_chains:
        - 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_certificates:
                  - certificate_chain:
                      filename: /etc/envoy/certs/server/orders-api.chain.pem
                    private_key:
                      filename: /etc/envoy/certs/server/orders-api.key.pem
                validation_context:
                  trusted_ca:
                    filename: /etc/envoy/certs/ca/ca.cert.pem
                  match_typed_subject_alt_names:
                    - san_type: URI
                      matcher:
                        exact: spiffe://dev.example/ns/default/sa/checkout-bff
                    - san_type: URI
                      matcher:
                        exact: spiffe://dev.example/ns/default/sa/admin-gateway
          filters:
            - name: envoy.filters.network.http_connection_manager
              typed_config:
                "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
                stat_prefix: orders_api
                route_config:
                  name: local_route
                  virtual_hosts:
                    - name: orders_api
                      domains: ["*"]
                      routes:
                        - match: { prefix: "/" }
                          route: { cluster: orders_api_local }
                http_filters:
                  - name: envoy.filters.http.router
                    typed_config:
                      "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
  clusters:
    - name: orders_api_local
      type: STATIC
      load_assignment:
        cluster_name: orders_api_local
        endpoints:
          - lb_endpoints:
              - endpoint:
                  address:
                    socket_address:
                      address: 127.0.0.1
                      port_value: 3000

require_client_certificate: true で mTLS を強制し、match_typed_subject_alt_namesSPIFFE URI allowlist を設定しています。

Upstream mTLS(Envoy がクライアント証明書を提示)

# envoy-upstream-mtls.yaml(cluster 部分)
clusters:
  - name: payment_svc_mtls
    type: STRICT_DNS
    load_assignment:
      cluster_name: payment_svc_mtls
      endpoints:
        - lb_endpoints:
            - endpoint:
                address:
                  socket_address:
                    address: payment-svc.internal
                    port_value: 8443
    transport_socket:
      name: envoy.transport_sockets.tls
      typed_config:
        "@type": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.UpstreamTlsContext
        sni: payment-svc.internal
        common_tls_context:
          tls_certificates:
            - certificate_chain:
                filename: /etc/envoy/certs/client/checkout-bff.cert.pem
              private_key:
                filename: /etc/envoy/certs/client/checkout-bff.key.pem
          validation_context:
            trusted_ca:
              filename: /etc/envoy/certs/ca/ca.cert.pem
            match_subject_alt_names:
              - exact: payment-svc.internal

SDS による証明書ホットリロード

本番ではファイルパス直指定より SDS で SPIRE / cert-manager Secret から動的取得する構成が一般的です。

common_tls_context:
  tls_certificate_sds_secret_configs:
    - name: orders_api_cert
      sds_config:
        api_config_source:
          api_type: GRPC
          grpc_services:
            - envoy_grpc:
                cluster_name: sds_cluster
  validation_context_sds_secret_config:
    name: client_ca
    sds_config:
      api_config_source:
        api_type: GRPC
        grpc_services:
          - envoy_grpc:
              cluster_name: sds_cluster

証明書更新時に Envoy プロセスを再起動せずに 新 SVID を反映 できるため、ローテーション downtime を最小化できます。

admin インターフェースでのデバッグ

curl -s localhost:9901/config_dump | jq '.configs[] | select(.["@type"] | contains("Secret"))'
curl -s localhost:9901/stats | grep ssl

nginx:proxy_ssl_verify による mTLS

nginx を エッジまたはサービス間ゲートウェイ として使い、inbound / outbound 両方で mTLS を設定する例です。

パターンA:inbound mTLS(クライアント証明書を要求)

upstream orders_api_upstream {
    server 127.0.0.1:3000;
    keepalive 32;
}

server {
    listen 8443 ssl;
    server_name orders-api.internal;

    ssl_certificate     /etc/nginx/mtls/server/orders-api.chain.pem;
    ssl_certificate_key /etc/nginx/mtls/server/orders-api.key.pem;
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_prefer_server_ciphers off;

    ssl_client_certificate /etc/nginx/mtls/ca/ca.cert.pem;
    ssl_verify_client on;
    ssl_verify_depth 2;

    location / {
        if ($ssl_client_verify != SUCCESS) {
            return 403;
        }
        proxy_pass http://orders_api_upstream;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-SSL-Client-S-DN $ssl_client_s_dn;
        proxy_set_header X-SSL-Client-Verify $ssl_client_verify;
    }
}

パターンB:outbound mTLS(上流へクライアント証明書を提示)

upstream payment_svc_mtls {
    server payment-svc.internal:8443;
    keepalive 16;
}

server {
    listen 443 ssl;
    server_name checkout.example.com;

    ssl_certificate     /etc/letsencrypt/live/checkout.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/checkout.example.com/privkey.pem;

    location /api/pay {
        proxy_pass https://payment_svc_mtls;
        proxy_http_version 1.1;
        proxy_set_header Host payment-svc.internal;

        proxy_ssl on;
        proxy_ssl_protocols TLSv1.2 TLSv1.3;
        proxy_ssl_server_name on;
        proxy_ssl_name payment-svc.internal;
        proxy_ssl_certificate     /etc/nginx/mtls/client/checkout-bff.cert.pem;
        proxy_ssl_certificate_key /etc/nginx/mtls/client/checkout-bff.key.pem;
        proxy_ssl_trusted_certificate /etc/nginx/mtls/ca/ca.cert.pem;
        proxy_ssl_verify on;
        proxy_ssl_verify_depth 2;
    }
}

Istio:STRICT mTLS と AuthorizationPolicy

Istio を使う場合、アプリコードを変えず PeerAuthentication で STRICT にできます。

apiVersion: security.istio.io/v1
kind: PeerAuthentication
metadata:
  name: default
  namespace: production
spec:
  mtls:
    mode: STRICT
---
apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
  name: orders-api-allow
  namespace: production
spec:
  selector:
    matchLabels:
      app: orders-api
  action: ALLOW
  rules:
    - from:
        - source:
            principals:
              - "cluster.local/ns/production/sa/checkout-bff"
      to:
        - operation:
            methods: ["GET", "POST"]
            paths: ["/orders/*"]

principals は mTLS で交換された SPIFFE ID に基づきます。PERMISSIVE モードから STRICT への移行は、全サイドカー注入完了後 に行い、段階的に namespace 単位で切り替えます。

PERMISSIVE から STRICT への移行

1

全 Pod にサイドカーが注入されていることを istioctl proxy-status で確認する

2

ステージング namespace で PeerAuthentication STRICT を適用し、e2e テストを実行する

3

本番は namespace ごとに STRICT をロールアウトし、TLS handshake failure メトリクスを監視する

4

レガシー平文呼び出しが残るサービスを特定し、サイドカー注入またはネイティブ mTLS 化する

5

全 namespace STRICT 完了後、PERMISSIVE 設定を削除し、NetworkPolicy で平文ポートを閉じる

障害切り分けとデバッグ

よくあるエラーと対処

症状原因対処
unknown caクライアントが異なるCAで署名された証明書CA 配布の統一、trust bundle 確認
certificate has expiredローテーション漏れrenewBefore アラート、自動更新
hostname mismatchSAN に接続先DNSがないCSR の subjectAltName 修正
connection resetrejectUnauthorized: true で検証失敗s_client で -verify_return_error 再現
nginx 502上流 proxy_ssl_verify 失敗error.log の upstream SSL 行を確認
Envoy 403 / TLS alertSPIFFE SAN allowlist 不一致config_dump で validation_context 確認

curl・OpenSSL デバッグチートシート

openssl s_client -connect orders-api.internal:8443 -servername orders-api </dev/null 2>/dev/null \
  | openssl x509 -noout -subject -dates -ext subjectAltName

curl -v --cacert ca/ca.cert.pem \
  --cert client/checkout-bff.cert.pem \
  --key client/checkout-bff.key.pem \
  https://localhost:8443/orders/ord_123

curl -v --cacert ca/ca.cert.pem https://localhost:8443/health

openssl x509 -noout -modulus -in client/checkout-bff.cert.pem | openssl md5
openssl rsa  -noout -modulus -in client/checkout-bff.key.pem  | openssl md5

ゼロトラスト多層防御:mTLS と HTTP セキュリティ

mTLS は サービス間・B2B API の Transport 層を守ります。エンドユーザー向けブラウザ には別の防御層が必要です。

  • CSP / HSTS / X-Frame-Options — ブラウザが解釈するレスポンスヘッダー。セキュリティヘッダー一覧と設定ガイドで nginx 一括設定と Report-Only 段階導入を解説
  • mTLScheckout-bfforders-api の east-west 通信。Pod 侵害時の横展開を抑止
  • JWT / OAuth2 — ユーザー操作の認可。サービスアカウントトークン(Workload Identity)
  • TLS 1.3 0-RTT — 性能とリプレイリスクのトレードオフ。0-RTT リスクガイドで無効化判断
flowchart LR
  subgraph browser [ブラウザ]
    U[ユーザー]
  end
  subgraph edge [エッジ]
    GW[API Gateway / nginx]
  end
  subgraph mesh [Kubernetes クラスタ]
    BFF[checkout-bff]
    ORD[orders-api]
    PAY[payment-svc]
  end
  U -->|HTTPS + セキュリティヘッダー| GW
  GW --> BFF
  BFF -->|mTLS SPIFFE| ORD
  BFF -->|mTLS SPIFFE| PAY

外部は HTTPS + セキュリティヘッダー、内部は mTLS — 層を混同せず、それぞれのチェックリストで棚卸しするのが実務的です。

セキュリティ上の注意

  1. 秘密鍵を Git にコミットしない.gitignore*.key.pem、本番は Secret Manager / KMS
  2. rejectUnauthorized: false を本番で使わない — 中間者攻撃を許容する
  3. ssl_verify_client optional は避ける — 証明書なし接続が残る
  4. CN allowlist だけに頼らない — SPIFFE ID / AuthorizationPolicy 等、より強い主体識別を検討
  5. 平文 HTTP をクラスタ内に残さない — メッシュ STRICT または NetworkPolicy + mTLS でカバー
  6. 0-RTT を安易に有効化しない — 内部 API でもリプレイリスクを評価する

関連記事

この記事は API・バックエンド テーマの一環です。あわせて読むと理解が深まる関連記事をまとめました。

トピック記事
APIキャッシュ設計ガイドAPIキャッシュ設計ガイド|ETag・Cache-Control・304・CDNキャッシュキーの実務
Web APIのエラーハンドリング設計Web APIのエラーハンドリング設計|RFC 9457・error_code・400/422/409の使い分け
Web APIのべき等性(Idempotency)設計Web APIのべき等性(Idempotency)設計|二重決済を防ぐ実装ガイド
API設計におけるページネーションの使い分け:オフセット型とカーソル型の比較API設計におけるページネーションの使い分け:オフセット型とカーソル型の比較
APIのレート制限(Rate Limiting)実装ガイドAPIのレート制限(Rate Limiting)実装ガイド|アルゴリズム選定と429エラーの適切なハンドリング
APIレート制限と429対応APIレート制限と429対応|X-RateLimit・Retry-Afterを使ったクライアント実装
WebHook設計のベストプラクティスWebHook設計のベストプラクティス|署名・再試行・べき等性で信頼性を担保する
ZodでAPI境界のランタイム検証を設計するZodでAPI境界のランタイム検証を設計する|z.infer・Expressミドルウェア・zod-to-openapi(2026)
OpenAPI 3.1仕様のCIバリデーション完全ガイドOpenAPI 3.1仕様のCIバリデーション完全ガイド|Spectral Lint・Breaking Change検出・GitHub Actions
GraphQL N+1 問題の解決GraphQL N+1 問題の解決|DataLoader バッチング実装と Apollo Server 完全ガイド
Circuit Breaker + Retry パターン実装ガイドCircuit Breaker + Retry パターン実装ガイド|Node.js opossum/cockatiel・指数バックオフ・Jitter
Sagaパターン完全ガイドSagaパターン完全ガイド|Orchestration vs Choreography と補償トランザクション(Node.js)

まとめ

項目推奨
小規模/PoCOpenSSL で CA 発行 → Node.js requestCert / Envoy require_client_certificate
中〜大規模SPIRE または Istio/Linkerd STRICT mTLS + AuthorizationPolicy
証明書寿命90日以下 + 自動ローテーション + overlap
主体識別SPIFFE ID(URI SAN)を CN より優先
ブラウザ向けセキュリティヘッダー設定(CSP・HSTS 等)
TLS 1.3 設定0-RTT リスク評価
検証openssl s_client、curl --cert、ローテーション障害ドリル

mTLS は「ネットワークの内側は安全」という前提を捨て、すべてのサービス通信を明示的に認証する ゼロトラストの実装パターンです。「mTLS マイクロサービス」「相互TLS 設定」で求められる OpenSSL コマンド、Node.js https.createServer、Envoy downstream/upstream TLS、nginx proxy_ssl_verify の完全な設定例を本記事で揃えました。まずステージングで CA から手動構築し、運用負荷が見えた段階で SPIRE・cert-manager・サービスメッシュへ移行するのが現実的なロードマップです。