概要
「mTLSを有効にしたのに、なぜ不正なクライアントがバックエンドまで到達しているのか」
「証明書を1枚追加しただけなのに、なぜ既存ユーザーが繋がらなくなったのか」
「認証が失敗しているはずなのに、ALBのアクセスログには何も残っていない」
これらの疑問はすべて、mTLSの「設定の仕方」ではなく「ALBが何をしているか」を知らないことから生まれます。本記事では、mTLSの基本的な仕組みと、よくある障害のパターンを解説します。
1. mTLSとは — 「サーバーがクライアントを確認する」通信
通常のHTTPS(TLS)では、クライアントが「このサーバーは本物か」をサーバー証明書で確認します。サーバーはクライアントが誰かを問いません。mTLS(Mutual TLS、相互TLS)は、これに加えて サーバーもクライアントに証明書の提示を求め、本物か確認する 仕組みです。
mTLSが向く場面:
- B2B連携(取引先のシステムとAPI接続する)
- 管理者向け画面(特定の端末・ユーザーだけ許可したい)
- 内部マイクロサービス間の認証
IP制限は「同じIPからなら誰でも通す」、Basic認証はパスワード漏洩リスクがあります。mTLSは「その秘密鍵を持つクライアントだけ」という証明書ベースの認証なので、より強固です。
ただし mTLS は「誰が来たか(認証)」を担うのみで、「何をしたか(入力の検証)」は担いません。SQLインジェクションなどの攻撃への対策には別途WAFが必要です。
2. mTLSと他認証方式の違い
| 方式 | セキュリティ | 運用負荷 |
|---|---|---|
| Basic認証 | 低 | 低 |
| IP制限 | 中 | 低 |
| VPN | 高 | 中 |
| mTLS | 非常に高い | 高 |
3. まず決める分岐 — verify と passthrough
ALBのmTLSには2つのモードがあり、どちらを選ぶかで「誰が証明書を検証するか」が変わります。
| モード | 検証の主体 | バックエンドへの渡し方 | 向いている場面 |
|---|---|---|---|
| mTLS verify | ALB(Trust Storeで検証) | 検証済み証明書のメタ情報をヘッダーで付与 | ALBに認証を任せてアプリをシンプルにしたい |
| mTLS passthrough | バックエンドアプリ(ALBは検証しない) | クライアント証明書をそのままバックエンドへ渡す | アプリ側で独自の認証・認可を実装したい |
passthroughの大きな落とし穴: ALBは証明書チェーンを検証せず、そのままバックエンドに渡すだけです。アプリ側が証明書を検証していなければ、誰でも通ってしまいます。「ALBで弾かれているはず」は verify モードのみ の話です。
以降は、より広く使われる verify モード を前提に説明します。
4. ALBが行う証明書検証の流れ
verify モードでは、ALBはHTTPリクエストを受け付ける前にクライアント証明書の検証を行います。
クライアントが接続 → ALBがクライアント証明書を要求
↓
証明書が提示されない → 接続を拒否(→ パターン①)
↓
Trust Store の CA と照合(発行元が信頼できるか)
→ 一致しない → 拒否(→ パターン②/④)
↓
有効期限チェック → 期限切れ → 設定次第で拒否(→ パターン③)
↓
失効チェック(CRL登録時のみ)→ 失効済み → 拒否(→ パターン⑤)
↓
認証成功 → ヘッダーを付与してバックエンドへ転送
重要なポイント: 証明書なし・CA不一致・期限切れ拒否・失効による失敗は、HTTPリクエストが成立する前に起きます。そのため、ALBのアクセスログには一切記録が残りません(理由は第5章)。
5. バックエンドへ渡される証明書情報
verify モードで認証に成功すると、ALBはクライアント証明書のメタ情報をヘッダーとして付与します。アプリ側はこのヘッダーを使って「どのクライアントか」を識別・認可できます。
| ヘッダー | 内容 |
|---|---|
X-Amzn-Mtls-Clientcert-Serial-Number |
証明書のシリアル番号 |
X-Amzn-Mtls-Clientcert-Issuer |
発行者 |
X-Amzn-Mtls-Clientcert-Subject |
サブジェクト(識別名) |
X-Amzn-Mtls-Clientcert-Validity |
有効期間 |
passthrough モードでは証明書チェーン全体が X-Amzn-Mtls-Clientcert(無印)ヘッダーに入ります。verify と passthrough でヘッダー名が異なるため、モードを混同するとアプリ側でずっと値が取れない状態になります。
※ 公式リンク Application Load Balancer での TLS による相互認証
6. なぜ「ログに何も出ない」のか
mTLS障害の切り分けで最初に理解すべき点: 失敗した「層」によって、記録が残る場所が変わります。
| 失敗の層 | 症状 | 記録が残る場所 |
|---|---|---|
| 証明書検証段階(HTTPより前) | ブラウザにSSLエラー |
接続ログ、ClientTLSNegotiationErrorCount
|
| アプリ層(HTTP成立後) | HTTP 4XX / 5XX | ALBアクセスログ、アプリログ |
ALBのアクセスログは「HTTPリクエストが成立してから記録される」ため、証明書検証で弾かれた接続は記録されません。
これを検知するには 接続ログ(connection logs) を別途有効化する必要があります。接続ログにはクライアントIP・証明書情報・接続成否が残ります。mTLSを導入する際は必ず有効化しましょう。
アクセスログに何も出ていない ≠ 正常。証明書検証が大量に失敗していても、アクセスログは静かなままです。
7. よくある障害パターン
パターン① 証明書が配布されていない
- 症状: ブラウザにSSLエラー。接続が確立しない。
- 原因: クライアントに証明書がインストールされていない。
-
確認:
ClientTLSNegotiationErrorCountの増加、接続ログの失敗記録。
パターン② CA不一致
- 症状: 接続が確立しない(HTTPの403ではない点に注意)。
- 原因: 提示された証明書の発行元CAが、Trust Storeに登録されていない。
-
確認:
ClientTLSNegotiationErrorCountの増加、接続ログ。
パターン③ 証明書の期限切れ
- 症状: 環境によって繋がる・繋がらないが食い違う。
-
原因: verify モードには期限切れ証明書の許否設定があり、デフォルトは拒否。
ignoreに設定すると期限切れでも通過する。「期限切れなのに繋がる」報告があったらこの設定を確認する。 - 確認: リスナーの期限切れ設定の確認、接続ログ。
パターン④ Trust Store更新ミス
- 症状: 新しいCAで発行した証明書の利用者だけ失敗。または更新直後に広範囲で失敗。
- 原因: 新CAをバンドルに含め忘れた、または「1枚だけ追加」操作で既存CAを消してしまった。
-
確認:
ClientTLSNegotiationErrorCountの増加。更新作業のタイムスタンプと症状開始時刻の相関を確認。
パターン⑤ CRLによる失効
- 症状: 有効期限内なのに特定クライアントだけが失敗。
- 原因: CRLに登録された証明書は期限内でも拒否される。CRL更新のタイミングずれや誤登録も疑う。
- 確認: 接続ログの失敗記録。対象クライアントの証明書シリアル番号をCRLと突き合わせる。
8. Trust Store — 運用で事故になりやすい仕様
Trust Storeとは、ALBが信頼する証明書の一覧を保存する仕組みです。
社員証の名簿のようなものと考えると分かりやすいでしょう。ALBは「この名簿に載っているCAが発行した証明書だけを信頼する」という動作をします。CA証明書をS3に置き、Trust Storeがそれを参照します。
運用上の重要な注意点:
-
CA証明書は「まとめて1ファイル」で更新する。個別追加はできない。
証明書を1枚追加するだけでも、全CA証明書をまとめたファイルを再アップロードする必要があります。「1枚だけ追加しよう」と既存ファイルを上書きしてしまい、既存CAが消えて広範囲の接続不可になる事故が多いです(→ パターン④)。 -
1つのリスナーに関連付けられるTrust Storeは1つのみ。1つのTrust Storeを複数リスナーで共有することは可能。
-
CRL(証明書失効リスト) を登録すると、有効期限内でも特定の証明書を拒否できます。
主なクォータ:
| 項目 | 値 |
|---|---|
| Trust Store / アカウント | 20 |
| CA証明書 / Trust Store | 25(要申請で変更可) |
| verify モードのリスナー / LB | 2(変更不可) |
最後の制限は設計上重要です。verify モードは1つのLBにつき最大2リスナーまで(申請しても変更不可)。大量のセキュアリスナーすべてに verify を付ける構成は組めません。
※ 公式リンク Application Load Balancer のクォータ
9. 切り分けの手順
ステップ1: TLS層かHTTP層かを判断する
ClientTLSNegotiationErrorCount が増加している?
Yes → 証明書検証で失敗(パターン①〜⑤)
→ 接続ログで詳細を確認
No → ハンドシェイクは通過。HTTP層の問題
→ HTTPCode_ELB_4XX/5XX とアクセスログを確認
ステップ2: 接続ログで原因を絞る
- 証明書が提示されているか → なければパターン①
- CAバンドルと照合 → 辿れなければパターン②/④
- 有効期限 → 超過ならパターン③(許否設定も確認)
- シリアル番号をCRLと突合 → 一致ならパターン⑤
確認するメトリクス(namespace: AWS/ApplicationELB):
| メトリクス | 確認目的 |
|---|---|
ClientTLSNegotiationErrorCount |
証明書検証失敗の検知。Sum で監視 |
HTTPCode_ELB_4XX_Count |
HTTP成立後のクライアントエラー |
HTTPCode_ELB_5XX_Count |
ターゲット側のエラー |
RequestCount |
成立したリクエスト数 |
注意:
ClientTLSNegotiationErrorCountはCipher不一致など他のTLSエラーも含む。増加=mTLS起因とは断定せず、接続ログで裏付けを取る。
10. 多層防御における位置付け
mTLSは万能ではなく、他の防御層と役割が異なります。
| 仕組み | 担う役割 |
|---|---|
| mTLS | 誰が来たか(認証) |
| WAF | 何を送ってきたか(攻撃遮断・入力検証) |
| IP制限 | どこから来たか(ネットワーク制限) |
mTLSを通過した正規クライアントが不正なペイロードを送る可能性は残るため、WAFは不要になりません。組み合わせて使うのが基本です。
推奨構成:
CloudFront → WAF → ALB(mTLS verify) → バックエンド
複数の証明書を持つクライアント環境では、どの証明書を提示すべきか判断できず接続失敗することがあります。ALBの Advertise CA subject names(認証局(CA)の件名) を有効にすると、ALBが信頼するCAの一覧をクライアントに提示し、クライアントが適切な証明書を選びやすくなります。
11. よくある誤解
| 誤解 | 実際 |
|---|---|
| mTLSを設定すればWAFは不要 | 役割が違う。mTLS=誰か / WAF=何をしたか |
| ALBが必ず不正クライアントを弾いてくれる | verify モードのみ。passthrough はアプリ側が責任を持つ |
| 証明書を1枚だけ追加できる | Trust Store はバンドル単位での更新。個別追加不可 |
| アクセスログに何も出ない = 正常 | 証明書検証失敗はアクセスログに記録されない |
| 有効な証明書があれば接続エラーは起きない | 複数証明書環境では選択失敗あり。Advertise CA subject names で低減 |
12. 簡易的な検証リソース構成
構成図
Route53
└─ ALB (mTLS verify)
├─ Trust Store ← S3 (CA バンドル)
└─ Target Group
└─ EC2 (Nginx)
HTTPS リスナー1つと EC2 ターゲット1台があれば、mTLS の基本動作を確認できます。
Terraform での環境構築
サンプルコード一式は GitHub で公開しています:
aws-alb-mtls-sample
データソース(AMI)
data "aws_ami" "al2023" {
most_recent = true
owners = ["amazon"]
filter {
name = "name"
values = ["al2023-ami-*-x86_64"]
}
}
セキュリティグループ(ALB / EC2)
# ALB: インターネットから 443 を受け付ける
resource "aws_security_group" "alb" {
name = "mtls-alb-sg"
vpc_id = var.vpc_id
ingress {
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
# EC2: ALB SG からの 80 のみ許可
resource "aws_security_group" "ec2" {
name = "mtls-ec2-sg"
vpc_id = var.vpc_id
ingress {
from_port = 80
to_port = 80
protocol = "tcp"
security_groups = [aws_security_group.alb.id]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
S3 バケット / Trust Store
resource "aws_s3_bucket" "ca_bundle" {
bucket = "mtls-ca-bundle"
}
resource "aws_s3_bucket_versioning" "ca_bundle" {
bucket = aws_s3_bucket.ca_bundle.id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_s3_object" "ca_bundle" {
bucket = aws_s3_bucket.ca_bundle.id
key = "ca-bundle.pem"
source = var.ca_bundle_local_path
etag = filemd5(var.ca_bundle_local_path)
depends_on = [aws_s3_bucket_versioning.ca_bundle]
}
resource "aws_lb_trust_store" "mtls" {
name = "mtls-trust-store"
ca_certificates_bundle_s3_bucket = aws_s3_bucket.ca_bundle.bucket
ca_certificates_bundle_s3_key = "ca-bundle.pem"
}
ALB / ターゲットグループ / HTTPS リスナー(mTLS 設定の核心)
resource "aws_lb" "main" {
name = "mtls-alb"
load_balancer_type = "application"
subnets = var.public_subnet_ids
security_groups = [aws_security_group.alb.id]
}
resource "aws_lb_target_group" "main" {
name = "mtls-tg"
port = 80
protocol = "HTTP"
vpc_id = var.vpc_id
health_check {
path = "/"
}
}
resource "aws_lb_listener" "https" {
load_balancer_arn = aws_lb.main.arn
port = 443
protocol = "HTTPS"
ssl_policy = "ELBSecurityPolicy-TLS13-1-2-2021-06"
certificate_arn = var.certificate_arn
mutual_authentication {
mode = "verify"
trust_store_arn = aws_lb_trust_store.mtls.arn
}
default_action {
type = "forward"
target_group_arn = aws_lb_target_group.main.arn
}
}
EC2(Nginx ターゲット)
resource "aws_instance" "target" {
ami = data.aws_ami.al2023.id
instance_type = "t3.micro"
subnet_id = var.private_subnet_id
vpc_security_group_ids = [aws_security_group.ec2.id]
user_data = <<-EOF
#!/bin/bash
dnf install -y nginx
echo "<h1>mTLS OK</h1>" > /usr/share/nginx/html/index.html
systemctl enable --now nginx
EOF
tags = {
Name = "mtls-target"
}
}
resource "aws_lb_target_group_attachment" "target" {
target_group_arn = aws_lb_target_group.main.arn
target_id = aws_instance.target.id
port = 80
}
使用する変数(variables.tf で定義):
| 変数 | 説明 |
|---|---|
vpc_id |
検証用 VPC の ID |
public_subnet_ids |
ALB を配置するパブリックサブネット(2AZ 以上) |
private_subnet_id |
EC2 を配置するプライベートサブネット |
certificate_arn |
ACM に登録済みのサーバー証明書 ARN(下記「サーバー証明書の作成と ACM へのインポート」で取得) |
ca_bundle_local_path |
CA バンドル PEM のローカルパス(デフォルト: ../key/ca-bundle.pem) |
証明書の準備(OpenSSL)
検証には本物の証明書は不要です。OpenSSL で自作した証明書を使います。
作業順序:
- サーバー証明書を作成して ACM にインポート → ARN を取得
-
terraform.tfvarsに ARN を設定 - Root CA・クライアント証明書を作成
- CA バンドルファイルをローカルに準備(
cp ca.crt ca-bundle.pem) -
terraform apply(S3 バケット作成 → CA バンドルのアップロード → Trust Store 作成を自動実行)
サーバー証明書の作成と ACM へのインポート
ALB の HTTPS リスナーに使うサーバー証明書です。terraform apply の前に ACM へ登録し、出力された ARN を terraform.tfvars に設定します。
# サーバー秘密鍵
openssl genrsa -out server.key 2048
# 自己署名サーバー証明書(1年有効)
openssl req -x509 -new -nodes -key server.key -sha256 -days 365 \
-subj "/CN=mtls-sample.example.com" \
-out server.crt
# ACM にインポート
aws acm import-certificate \
--certificate fileb://server.crt \
--private-key fileb://server.key \
--region ap-northeast-1
コマンド実行後、次の形式で ARN が出力されます。
{
"CertificateArn": "arn:aws:acm:ap-northeast-1:123456789012:certificate/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
}
この ARN を terraform.tfvars の certificate_arn に設定してから次の手順に進みます。
注意: 自己署名証明書を使う場合、ブラウザや curl はサーバー証明書の検証でエラーを出します。検証手順では
-k/--insecureフラグを付けて実行してください。
Root CA の作成
# CA 秘密鍵
openssl genrsa -out ca.key 2048
# CA 証明書(自己署名・10年有効)
openssl req -x509 -new -nodes -key ca.key -sha256 -days 3650 \
-subj "/CN=Test Root CA" \
-out ca.crt
クライアント証明書の作成(CA 署名)
# クライアント秘密鍵
openssl genrsa -out client.key 2048
# CSR の生成
openssl req -new -key client.key \
-subj "/CN=test-client" \
-out client.csr
# CA で署名(1年有効)
openssl x509 -req -in client.csr -CA ca.crt -CAkey ca.key \
-CAcreateserial -days 365 -sha256 \
-out client.crt
CA 不一致テスト用の証明書(パターン②の再現)
# 別の CA(Trust Store に登録しない)
openssl genrsa -out other-ca.key 2048
openssl req -x509 -new -nodes -key other-ca.key -sha256 -days 3650 \
-subj "/CN=Other Root CA" \
-out other-ca.crt
# 別 CA で署名したクライアント証明書
openssl genrsa -out other-ca-client.key 2048
openssl req -new -key other-ca-client.key \
-subj "/CN=other-client" \
-out other-ca-client.csr
openssl x509 -req -in other-ca-client.csr -CA other-ca.crt -CAkey other-ca.key \
-CAcreateserial -days 365 -sha256 \
-out other-ca-client.crt
Trust Store 用の CA バンドルを準備する
# バンドルファイル作成(CA が複数ある場合は cat で連結)
cp ca.crt ca-bundle.pem
S3 へのアップロードと Trust Store 作成は terraform apply が自動的に行います(aws_s3_object リソースが ca_bundle_local_path 変数のパスからファイルを読み込んでアップロードします)。
other-ca.crtは Trust Store に含めません。これがパターン②(CA不一致)の再現に使います。
検証手順
まず接続ログを有効化した上で、以下の3パターンを順に試します。
① 証明書あり(成功を確認)
curl -k --cert client.crt --key client.key https://your-alb.example.com/
# → 200 OK。X-Amzn-Mtls-Clientcert-* ヘッダーがバックエンドに届くか確認
返却例
② 証明書なし(パターン①の再現)
curl -k https://your-alb.example.com/
# → SSL エラー。アクセスログには出ず、接続ログと ClientTLSNegotiationErrorCount に記録される
返却例
③ CA 不一致(パターン②の再現)
curl -k --cert other-ca-client.crt --key other-ca-client.key https://your-alb.example.com/
# → SSL エラー。Trust Store に登録されていない CA の証明書を提示した場合
返却例
総括
3パターンを通して「どのログ・メトリクスにどう出るか」を体で確認しておくと、本番障害時の切り分けが速くなります。
まとめ
ALBのmTLSは、「特定の証明書を持つクライアントだけを許可する」ための認証機能です。
まずは verify モードから始めるのがおすすめです。
接続できない場合は、
ClientTLSNegotiationErrorCount- 接続ログ
- Trust Store
の順で確認すると原因を特定しやすくなります。
検証環境で
- 証明書あり
- 証明書なし
- CA不一致
を再現しておくと、本番障害時の切り分けが大幅に楽になります。





