mTLS — 証明書結び付けアクセストークン
mTLS(RFC 8705)は、TLS ハンドシェイク中にクライアントを認証した X.509 証明書にアクセストークンを結び付ける仕組みです。OP は証明書の SHA-256 指紋(thumbprint)を cnf.x5t#S256 として発行トークンに書き込み、リソースサーバは API 呼び出し時に提示された証明書の指紋を照合します。トークンのバイト列だけが漏れても無価値で、攻撃者は証明書 と その秘密鍵も併せて入手しなければ通せません。
mTLS は既に PKI を運用している環境で特に強みを発揮します。たとえば B2B のサービスメッシュ、オープンバンキング、内部 CA が全関係者に証明書を発行しているバックエンド API などです。結び付きが TLS 層に乗っているため、アプリケーションコードはリクエストごとに何かを署名する必要がありません。代償として、TLS 終端(リバースプロキシ、ロードバランサ)が検証済み証明書を OP まで運ぶよう設定する必要があります。
RFC 8705 は 2 つの部分から成り、feature.MTLS はその両方を有効にします。1 つはアクセストークンをクライアント証明書に結び付ける仕組み(cnf.x5t#S256)です。もう 1 つは、証明書そのものでクライアントを認証する tls_client_auth と self_signed_tls_client_auth です(証明書によるクライアント認証)。どちらか一方だけを使うこともでき、private_key_jwt のクライアントに証明書結び付きトークンを発行することもできます。
このページで触れる仕様
- RFC 8705 — Mutual-TLS Client Authentication and Certificate-Bound Access Tokens
- RFC 7800 — Confirmation (
cnf) claim - RFC 5280 — X.509 PKI 証明書
- FAPI 2.0 Security Profile — Final
証明書を信頼する方法
feature.MTLS を有効にすると、OP は直接 TLS ハンドシェイク、または後述する信頼済みプロキシ経路からクライアント証明書を取得し、RFC 8705 の thumbprint を計算して発行トークンに結び付けます。トークンの結び付けだけなら、チェーン検証は通常 TLS 終端側の責務です。OP 自身にもチェーン検証をさせる場合は、nil ではない公開 pool を渡します:
import "crypto/x509"
pool := x509.NewCertPool()
pool.AppendCertsFromPEM(caPEM)
op.WithFeature(feature.MTLS),
op.WithMTLSRootCAs(pool),WithMTLSRootCAs(pool *x509.CertPool) は公開 option です。nil ではない pool は、ハンドシェイク証明書と WithMTLSProxy で選ばれた証明書の両方に適用され、client authentication 用のチェーン検証が必要になります。nil は設定エラーです。何も信頼しない場合は空の x509.NewCertPool() を渡してください。トークンの結び付けだけで、直接 TLS スタックまたは信頼済みプロキシが外部クライアントのチェーンを検証済みなら、この option は省略できます。プロキシの後ろで tls_client_auth のクライアントを認証する構成では省略できません(後述)。
Confirmation claim — cnf.x5t#S256
OP は mTLS 認証されたクライアントにトークンを発行する際、DER エンコードした証明書の SHA-256 ハッシュ値(RFC 8705 §3)を計算し、アクセストークンに cnf.x5t#S256 として書き込みます。以後このアクセストークンを使うリクエストは 同じ証明書 で TLS 接続を確立する必要があり、リソースサーバは観測した証明書をハッシュして cnf.x5t#S256 と比較します。
結び付けが行われるのは、token リクエストが証明書を提示したときだけです。提示がなければ通常の bearer トークンになります。ただし profile.FAPI2Baseline、profile.FAPI2MessageSigning、profile.FAPICIBA は 400 invalid_request で拒否します。リフレッシュトークンも全クライアント種別で同じように結び付きます。連鎖は証明書の thumbprint を覚えていて、リフレッシュのたびに同じ証明書を提示しなければ invalid_grant で失敗します。mTLS は連鎖の途中で bearer から結び付きへ切り替わることはなく、結び付きは発行時に固定されます。
/userinfo では、証明書に結び付いたトークンに対して証明書がない、または別のものだった場合、401 と invalid_token で拒否します。応答からは理由を区別できません。OP の introspection 応答は結び付きを cnf で報告し、token_type は Bearer のままです。
cnf 自体は DPoP と共通の仕組み(RFC 7800)ですが、メンバ名 が異なります — DPoP は jkt、mTLS は x5t#S256。1 つのトークンに両方のメンバを持たせることもできます。両方の結び付きがある場合、利用側 endpoint は AND 条件として検証します。つまり DPoP proof は jkt に、提示証明書は x5t#S256 に一致しなければなりません。本ライブラリは /userinfo でこの AND 条件を適用し、device-code と CIBA の grant への結び付けでも記録されたすべての方式を要求します。
なぜ証明書全体ではなく thumbprint なのか
DPoP の JWK thumbprint と同じ理由です。固定長のハッシュ値は再エンコードを跨いでも安定し、比較も安価で、JWT 内に十分収まる短さです。SHA-256 は RFC 8705 §3 が固定で指定しており、交渉の余地はありません。
リバースプロキシ構成
本番では OP が自前で TLS を終端しない構成が多くあります。前段の nginx / envoy / AWS ALB / クラウド LB が TLS を復号し、OP には平文 HTTP で渡します。OP に届いた時点でクライアント証明書はすでに接続から失われているため、プロキシが X-SSL-Cert のような HTTP ヘッダで前送りする必要があります。
OP は どのヘッダから読むか と どの IP 範囲がそのヘッダを設定してよいか の両方を知る必要があります。後者を抜くと、インターネット側の任意のクライアントが偽造ヘッダを送って、認証済みクライアントになりすませてしまいます。
op.WithMTLSProxy(op.MTLSProxy{
HeaderName: "X-SSL-Cert",
TrustedProxies: []string{"10.0.0.0/8"},
})op.MTLSProxy には 2 つのフィールドがあります。
HeaderNameは空にできません。ヘッダパスを無効化したい場合はオプション自体を渡さないでください。TrustedProxiesは、ヘッダを設定してよい CIDR の一覧です。空ならop.WithTrustedProxiesに渡した範囲を使います。両方が空だとop.Newが失敗するので、設定ミスで許可リストが黙って広がる経路は塞がれています。
WithMTLSProxy と WithMTLSRootCAs が効くのは feature.MTLS と併用したときだけです。feature なしでどちらかを設定すると、プロファイルに関係なく op.New がエラーを返します。どこからも読まれない設定は、無視してよいものではなく設定ミスとして扱います。
ヘッダには、CERTIFICATE の PEM ブロックをちょうど 1 つ載せた単一のフィールドが必要です。フィールドの重複、証明書チェーン、末尾の余分なデータは不正な証明書(invalid_request)として拒否します。複数のホップが別々のリーフ証明書を選ぶことを防ぐためです。信頼済みプロキシからのリクエストにヘッダがなければ、プロキシ自身の通信用証明書が接続上にあっても、証明書の提示なしとして扱います。
直接経路では http.Request.TLS.PeerCertificates を読みます。証明書が届くには、Go の HTTP サーバでクライアント証明書を要求する設定(tls.Config.ClientAuth)が必要です。プロキシ経路はリクエストの RemoteAddr が設定済みの信頼済み CIDR 内にある場合だけ参照され、範囲外からのヘッダは無視されて fail closed になります。信頼済みプロキシから来たリクエストでは、前送りされた証明書が正本であり、プロキシから OP までの TLS ホップにある証明書より優先されます。プロキシの通信用証明書は OAuth クライアント証明書として扱いません。
(*op.Provider).MTLSProxy() は記録済みの op.MTLSProxy を返し、オプションが無ければゼロ値を返します。返される TrustedProxies は、op.WithTrustedProxies へのフォールバック後の実効値で、CIDR 文字列の新しいスライスです。組み込み側のエッジ側コードはこの公開された値を使ってヘッダ除去ポリシーを OP と揃えられ、内部の verifier パッケージを import する必要はありません。
証明書によるクライアント認証
RFC 8705 §2 の 2 方式のどちらかで登録したクライアントは、クライアントを認証するすべてのエンドポイント(token、PAR、introspection、revocation、CIBA backchannel authentication、device authorization)で認証できます。feature.MTLS を有効にすると、discovery はこれらのエンドポイントの対応認証方式に 2 方式を追加します。feature がなければ広告しません。
| 方式 | シード型 | OP が検証する内容 |
|---|---|---|
tls_client_auth(RFC 8705 §2.1) | op.TLSClientAuthClient | 証明書のチェーンが検証でき、登録された識別子のいずれか 1 つに証明書が一致する |
self_signed_tls_client_auth(RFC 8705 §2.2) | op.SelfSignedTLSClientAuthClient | 証明書の公開鍵が、クライアントの JWKS または JWKSURI の文書にある鍵と一致する。チェーンは辿らない |
op.TLSClientAuthClient に指定できるのは、SubjectDN、SANDNS、SANURI、SANIP、SANEmail のうちちょうど 1 つです。RFC 8705 §2.1.2 の 5 つの登録値に対応します。0 個または複数なら登録エラーになります。op.SelfSignedTLSClientAuthClient には JWKS か JWKSURI を指定し、JWKSURI は他のクライアントの jwks_uri と同じ SSRF 対策付きの経路(op.WithAllowPrivateNetwork(op.OutboundJWKS)、op.WithJWKSHTTPTransport)で取得されます。動的登録も同じメタデータを受け付け、返します。
tls_client_auth は、チェーンを検証していない証明書を受け付けません。直接の TLS 接続では、Go のサーバが ClientCAs でクライアント証明書を検証済み(r.TLS.VerifiedChains が空でない)であることを意味します。op.WithMTLSProxy 経由では OP が前送りされた証明書を op.WithMTLSRootCAs で検証するため、このオプションが必須です。tls_client_auth と WithMTLSProxy を組み合わせて root pool がなければ、op.New が失敗します。OP が検証していない前送り証明書で、PKI 方式のクライアントを認証することはありません。
クライアントの認証に使った証明書は、そのまま発行されるアクセストークンの結び付け先(cnf.x5t#S256)になります。別の証明書や proof は要りません。認証に失敗すると invalid_client を返して監査イベントを記録します。他の認証方式と同じ扱いです。
実装例
プロキシの後ろでの mTLS の最小構成:
import (
"github.com/libraz/go-oidc-provider/op"
"github.com/libraz/go-oidc-provider/op/feature"
)
op.New(
/* 必須オプション */
op.WithFeature(feature.MTLS),
op.WithMTLSProxy(op.MTLSProxy{
HeaderName: "X-SSL-Cert",
TrustedProxies: []string{"10.0.0.0/8"},
}),
op.WithMTLSRootCAs(pool), // プロキシ越しの tls_client_auth クライアントには必須
)OP が自前で TLS を終端する環境(テスト、シングルテナント on-prem 等)では、WithMTLSProxy 行は省略可能です — ライブラリは http.Request.TLS.PeerCertificates から直接証明書を読みます。OP 自身にもチェーン再検証をさせる場合は、CA を追加した pool を WithMTLSRootCAs に渡してください。
op.WithProfile(profile.FAPI2Baseline) は [DPoP, MTLS] に対する RequiredAnyOf を課します。どちらも明示しなければ、プロファイルは DPoP を既定メンバーとして選びます。mTLS を使う構成では feature.MTLS を明示してください。その場合は mTLS が制約を満たすため DPoP 既定は追加されません。同じ feature で tls_client_auth と self_signed_tls_client_auth も使えるようになるので、FAPI のクライアントは証明書でも private_key_jwt でも認証できます。シード型の一覧は FAPI 2.0 Baseline にあります。
落とし穴
- TLS 終端が証明書を正しく前送りすること。 プロキシごとにヘッダ名とエンコードが違います。OP が受け付けるのは、生のまま、またはパーセントエンコードした単一の PEM 証明書(nginx の
$ssl_client_escaped_certなど)です。DER や、Envoy のx-forwarded-client-certのような複数フィールドの構造化された値は受け付けません。op.MTLSProxyのヘッダ名を固定し、プロキシ側で外部から届いた同名ヘッダを取り除いてください。 - 証明書の更新で binding が変わる。 トークンは発行時の leaf 証明書に結び付き、リフレッシュトークンの連鎖も同じです。クライアントの証明書がローテーションすると、古いアクセストークンはリソースサーバで通らなくなり、新しい証明書でのリフレッシュも
invalid_grantで失敗します。クライアントは認可からやり直す必要があるので、証明書の有効期間はリフレッシュトークンの寿命と合わせて決めてください。 feature.MTLSなしで証明書クライアントを登録しない。 discovery が 2 つの方式を広告するのは feature が有効なときだけです。op.TLSClientAuthClientやop.SelfSignedTLSClientAuthClientでシード登録するクライアントがあるなら、feature を有効にしてください。- 多段プロキシでの
RemoteAddrの意味。 OP の前にプロキシが 2 段ある場合、RemoteAddrに乗るのは 直前の プロキシの IP のみです。その IP がtrustedCIDRsに入っている必要があります。さらに外側のプロキシはヘッダ許可リストの対象外です(OP が直接見ないため)。
mTLS が向いているケース
- 既存 PKI を持つバックエンドサービス — 全サービスが内部 CA 発行のクライアント証明書を既に持っている環境では、新しい鍵管理面を増やさずに mTLS を導入できます。
- オープンバンキング・B2B サービスメッシュ — 制度やパートナー標準によってネットワーク層の mTLS が求められる場合があります。RFC 8705 はその上にトークン結び付けを乗せます。
- TLS 終端をすでに運用している運用チーム —
WithMTLSProxyの設定は一度きりの作業で、既存の nginx / envoy 設定の隣に自然に収まります。 - リクエストごとの署名コストを払いたくない制約クライアント — 結び付きが TLS 層にあるため、アプリ側は API 呼び出しごとに新規署名を作る必要がありません。
mTLS が向かないケース
- ブラウザ — 現在のブラウザはクライアント証明書を提示する手段が乏しく、SPA で mTLS を実用化するのは現実的ではありません。代わりに DPoP を使ってください。
- モバイルアプリ — 多くのプラットフォームはクライアント証明書をサポートしますが、プロビジョニングと更新の UX が芳しくありません。DPoP のリクエスト毎署名のほうがモバイル鍵ストアと相性が良いことが多いです。
- PKI が無い環境 — 単にクライアント証明書を発行するためだけに内部 CA を立ち上げるのは重い投資です。これから始めるなら、DPoP のほうが証明書ロジスティクス無しで送信者制約を導入できます。
- 異種混在環境 — SPA とバックエンドが混ざる環境では結局両方を運用することになりがちです。discovery に両方を出し、クライアントごとに使えるほうを選ばせる構成が現実的です。
次に読む
- DPoP (RFC 9449) — もう一方の送信者制約方式。クライアント保有鍵に結び付けます。
- 送信者制約 — 選定ガイド — 比較表と使い分けの指針。
- ユースケース: FAPI 2.0 Baseline —
private_key_jwtまたは証明書によるクライアント認証と、送信者制約を含む完全な組み込み例。 - 設計判断 — 仕様間トレードオフの整理。