ClaudeのWIFをSPIFFE/SPIREと連携する手順
SPIREが発行するJWT-SVIDをClaude APIの認証に使う設定を、OIDC Discovery ProviderとKubernetesの構成で説明する。
SPIREを使うのはどんな環境か
Workload Identity Federation(WIF)が対応するIdPは、AWS・Google Cloud・GitHub Actionsのような単一クラウドのネイティブIDだけではありません。SPIFFEはCNCFが定めるワークロードID発行の標準規格で、SPIREはそのオープンソース実装です。Pod・仮想マシン・ベアメタルのどこで動くワークロードでも、同じSPIFFE IDの仕組みでClaude APIにつなげます。
SPIFFEはワークロードごとにspiffe://<trust-domain>/<path>という安定したID URIを割り当て、SPIREはそれをJWT-SVID(JWT形式のSVID)として必要な時に発行します。JWT-SVIDは普通の署名付きJWTで、subクレームがSPIFFE ID、audクレームは取得時にワークロード側が指定します。AnthropicはOIDC互換のJWT-SVIDを発行するSPIFFE実装であれば、SPIREに限らずどれでも連携できます。商用のSPIFFE実装も対象です。
前提条件
- ワークロードIDが発行済みのSPIFFE環境(この記事の例はSPIRE Server/Agent)と、Claude APIを呼ぶワークロード向けの登録エントリ
- trust domain用のOIDC discoveryエンドポイント(SPIREではOIDC Discovery Provider)が公開HTTPSで到達可能であること、または登録用にエクスポート済みのJWKS
- JWT-SVIDの
issクレームを、federation issuerのissuer_urlとして登録する値に一致させる設定(discoveryモードならdiscoveryエンドポイントの公開URL、SPIREではjwt_issuerサーバー設定) - JWT-SVIDに
iatクレームが入ること(JWT-SVIDの仕様では必須ではなく、SPIREは自動で設定する。他の実装では設定が要る) - JWT-SVID(X.509-SVIDではない)がワークロードから取得可能であること
- Claude Consoleでサービスアカウント・federation issuer・federation ruleを作成できる権限
JWT-SVID取得時にリクエストするaudience値は常にhttps://api.anthropic.comです。spiffe-helperのjwt_audience、Workload APIのFetchJWTSVID呼び出し、federation ruleのaudienceマッチャーのすべてでこの値を使います。
SPIREを構成する
このセクションはSPIRE固有です。別のSPIFFE実装を使う場合は、そのプロバイダーのOIDC discoveryとJWT-SVID取得の手順に従い、Anthropic側の設定セクションから読み進めてください。
JWT issuerを揃える
AnthropicはJWT-SVIDのissクレームを登録済みのfederation issuerと突き合わせ、そのissuerのdiscoveryドキュメントからJWKSを取得して署名を検証します。ここで2つのSPIRE設定が同じURLを指している必要があります。SPIRE Serverのjwt_issuer(発行されるすべてのJWT-SVIDのissクレームになる値)と、OIDC Discovery Providerのdomains(discoveryドキュメントとJWKSを配信するホスト)です。
server {
trust_domain = "prod.example.com"
jwt_issuer = "https://oidc-discovery.prod.example.com"
default_jwt_svid_ttl = "5m"
# ...
}SPIREの既定のJWT-SVID寿命は5分と短く、継続的なローテーションが前提です。Anthropicのトークン交換エンドポイントは、identity tokenの寿命がfederation issuerに設定した上限(既定1時間)を超えると拒否します。これはSPIRE固有ではなく、すべてのSPIFFE実装に共通するルールなので、default_jwt_svid_ttlやエントリ単位の上書き値をこの上限以下に保ちます。
trust domain(prod.example.com)と発行者URLは別物で、同じホスト名である必要はありません。trust domainはsubの範囲を決め、発行者URLはAnthropicが署名鍵を取りに来る場所を決めます。
OIDC Discovery Provider側の設定にも同じホスト名をdomainsに含め、SPIRE ServerのAPIソケットに到達できるようにします。TLSはproviderのACME機能で終端するか、手前のロードバランサーに任せます。
domains = ["oidc-discovery.prod.example.com"]
server_api {
address = "unix:///run/spire/sockets/private/api.sock"
}
acme {
email = "platform@example.com"
tos_accepted = true
}この例のserver_apiは、providerをSPIRE Serverの特権APIソケットにつなぎます。providerがServer APIに触れてはいけない場合や、Serverに到達できないノードで動かす場合は、workload_apiブロック(socket_pathとtrust_domain)でSPIRE AgentのWorkload API経由にバンドルを取れます。
ワークロードを登録する
Claude APIを呼ぶ各ワークロードには、実行時のセレクタをSPIFFE IDにマッピングするSPIRE登録エントリが必要です。すでに登録済みならそのSPIFFE IDをfederation ruleのsubject_prefixに使い、未登録なら新規作成します。Kubernetes Podの場合、セレクタは通常namespaceとKubernetesサービスアカウントです。
# NODE_UIDはノードのUIDに置き換える
# kubectl get node <node-name> -o jsonpath='{.metadata.uid}'
spire-server entry create \
-spiffeID spiffe://prod.example.com/ns/inference/sa/worker \
-parentID spiffe://prod.example.com/spire/agent/k8s_psat/prod-cluster/NODE_UID \
-selector k8s:ns:inference \
-selector k8s:sa:workerこのparentIDは特定の1ノードのAgent IDです。クラスタ全体に効かせるときは、エントリの親をノードエイリアスにします。Kubernetes以外のワークロードはunix:uid:1000のようなホスト側のセレクタを使い、unix:pathは、Agentのunix workload attestorでdiscover_workload_path = trueにしたときだけ使えます。spire-controller-managerを入れたクラスタなら、ClusterSPIFFEIDリソースでエントリを宣言する手もあります。
spiffe-helperを動かす
spiffe-helperは、SPIRE Agentのソケットに接続してJWT-SVIDを取得し、ファイルに書き出して期限前に再取得するサイドカーです。
agent_address = "/run/spire/sockets/agent.sock"
cert_dir = "/var/run/secrets/anthropic.com"
daemon_mode = true
jwt_svids = [{
jwt_audience = "https://api.anthropic.com"
jwt_svid_file_name = "token"
}]Kubernetesでは、spiffe-helperをサイドカーとして動かし、アプリケーションコンテナとemptyDir(medium: Memory)を共有します。SPIRE Agentのソケットはホストからサイドカーにマウントし、共有ボリュームは両方のコンテナの/var/run/secrets/anthropic.comに置きます。アプリ側にはANTHROPIC_IDENTITY_TOKEN_FILE=/var/run/secrets/anthropic.com/tokenをセットします。SVIDがノードのディスクに書かれないよう、ボリュームはメモリ上です。VMやベアメタルでは、spiffe-helperをワークロードと並べてシステムサービスで動かし、共有ディレクトリを指させます。
Anthropic側でissuerとruleを作る
Claude ConsoleでSettings → Workload identityからConnect workload → Custom OIDCを選びます。ウィザードが作るリソースは、Admin APIにコードから送っても同じ結果になります。
federation issuer: discoveryモードでOIDC Discovery Providerの公開URLを登録します。
{
"name": "spire-prod",
"issuer_url": "https://oidc-discovery.prod.example.com",
"jwks": { "type": "discovery" }
}discoveryモードが使えない環境では、JWKSを自分で取得してinlineモードで登録します。次の2つは、鍵の更新を誰が担うかで分かれます。
discoveryとinline、どちらで登録するか
discovery
発行者URLは公開のHTTPS(ポート443、公開DNSのホスト名)で、IPアドレス直書きは使えません。SPIREが鍵を回しても登録の更新は要らず、Anthropic側のJWKSキャッシュが入れ替わるまで最大1分、署名エラーになることがあります。
inline
providerを公開しなくて済みます。取得はcurl https://oidc-discovery.prod.example.com/keysで、返ってきたkeys配列を登録します。issuer_urlはJWT-SVIDのissとの文字列比較にだけ使われ、Anthropicは接続しに行きません。SPIREは既定でCAと同じ周期(ca_ttl、24時間)で署名鍵を回すので、新しい鍵を先に足し、古い鍵は旧トークンが切れた後に消します。残った古い鍵は無期限に信頼され続けます。
inlineの更新を自動化したい場合は、SPIRE ServerのBundlePublisherプラグイン(aws_s3・gcp_cloudstorage・k8s_configmap)をformat = "jwks"にします。鍵のローテーションのたびに外部ストレージへJWKSが書き出され、それを使ってAdmin APIでissuerのinline鍵を更新できます。
発行者URLを内部ホスト名のままにしたい場合は、jwks.discovery_baseにdiscoveryを公開しているURLを別に指定する方法もあります。discovery_baseを設定するとissuer_urlは文字列比較にしか使われないので、jwt_issuerを非公開のホスト名にできます。providerをprivate CAのTLSで公開しているなら、ca_cert_pemで証明書を渡せます。
federation rule: JWT-SVIDのsub(SPIFFE ID)とspiffe-helperに要求させたaudを照合します。
{
"name": "spire-inference-worker",
"issuer_id": "fdis_...",
"match": {
"subject_prefix": "spiffe://prod.example.com/ns/inference/sa/worker",
"audience": "https://api.anthropic.com"
},
"target": { "type": "service_account", "service_account_id": "svac_..." },
"workspace_id": "wrkspc_...",
"oauth_scope": "workspace:developer",
"token_lifetime_seconds": 600
}token_lifetime_secondsはAnthropicアクセストークンの寿命であり、JWT-SVID自体の寿命ではありません。アクセストークンの更新はSDKが自動で処理します。
subject_prefixは完全一致か、末尾*の前方一致です。それより細かい条件が要る場合は、CELのconditionを使います。ルールのfdrl_...のIDは、ワークロードのANTHROPIC_FEDERATION_RULE_IDに入れます。
SVIDの5分とアクセストークンの寿命はどう噛み合うか
登場する時間の数字は5つあり、決める主体がそれぞれ違います。
SPIREとWIFの時間の数字
SVIDの寿命
5分
SPIREの既定。`default_jwt_svid_ttl`で変更
issuerの上限
1時間
`exp`と`iat`の差がこれを超えると拒否
アクセストークン
600秒
ルールの`token_lifetime_seconds`(コンソールの初期値)
時計のずれ
30秒
`exp`・`nbf`・`iat`に許される猶予
JWKSキャッシュ
最大1分
新しい署名鍵が通るまでの遅れ
発行されるアクセストークンの寿命は、ルールのtoken_lifetime_secondsと、提示したJWTの残り寿命の2倍のうち短い方です。ただし下限は60秒です。SVIDが5分なら残り寿命は最大300秒で、2倍の600秒がルールの値と一致します。交換のタイミングでSVIDの残りが100秒しかなければ、トークンは200秒になります。
jtiクレームを持つJWTは、1つのissuerに対して1回しか交換できません。SDKは更新のたびにANTHROPIC_IDENTITY_TOKEN_FILEを読み直すので、spiffe-helperがファイルを書き換える前に再交換が走ると、前回と同じトークンがjti_reusedで拒否されます。ファイルの更新をアクセストークンの寿命より十分短い間隔で回すと、更新がSDKの再交換に先行します。間隔を縮められないトークン源には、issuerのcheck_jtiを無効にする逃げ道がありますが、そのissuerの全ルールでリプレイ防止が外れます。SPIFFE実装がSVIDにjtiを入れているかは、次の検証で実物を見て分かります。
トークンを取得してClaude APIを呼ぶ
Anthropic SDKは、spiffe-helperが維持するファイルからJWT-SVIDを読む方法と、SPIFFE Workload APIを直接呼ぶcallableを渡す方法のどちらにも対応します。ファイル経由が最もシンプルで、どのSDK言語でも動きます。
JWT=$(cat "$ANTHROPIC_IDENTITY_TOKEN_FILE")
ACCESS_TOKEN=$(curl -sS https://api.anthropic.com/v1/oauth/token \
-H "content-type: application/json" \
--data @- <<JSON | jq -r .access_token
{
"grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer",
"assertion": "$JWT",
"federation_rule_id": "$ANTHROPIC_FEDERATION_RULE_ID",
"organization_id": "$ANTHROPIC_ORGANIZATION_ID",
"service_account_id": "$ANTHROPIC_SERVICE_ACCOUNT_ID",
"workspace_id": "$ANTHROPIC_WORKSPACE_ID"
}
JSON
)
curl https://api.anthropic.com/v1/messages \
-H "authorization: Bearer $ACCESS_TOKEN" \
-H "anthropic-version: 2023-06-01" \
-H "content-type: application/json" \
-d '{"model": "claude-opus-5-5", "max_tokens": 1024, "messages": [{"role": "user", "content": "Hello, Claude"}]}' \
| jq -r '.content[] | select(.type == "text") | .text'Workload APIクライアントがある言語(Pythonのpy-spiffe、Goのgo-spiffe)では、spiffe-helperを省いてSDKにcallableを渡せます。PythonではWorkloadIdentityCredentialsのidentity_token_providerに、SVIDを返す関数を渡します。GoのSDKではoption.WithFederationTokenProviderが同じ役割です。それ以外の言語は、spire-agent api fetch jwtでトークンをファイルに書き、ANTHROPIC_IDENTITY_TOKEN_FILEで読ませます。SDKはトークン交換のたびにこのcallableを呼ぶため、常に有効期限内のSVIDを提示できます。
検証方法
SDKを組み込む前に、SPIRE AgentからJWT-SVIDを直接取得し、federation ruleが期待するクレームと一致しているかを確認します。
spire-agent api fetch jwt \
-audience https://api.anthropic.com \
-socketPath /run/spire/sockets/agent.sock \
-output json \
| jq -r '.[0].svids[0].svid' \
| jq -rR 'split(".")[1] | gsub("-";"+") | gsub("_";"/") | @base64d | fromjson'Workload APIは呼び出し元のプロセスをattestationで確認するため、Kubernetesの登録エントリなら該当Podの中(kubectl exec経由など)で実行します。VM・ベアメタルならunix:セレクタに一致するユーザー・プロセスとして実行します。attestation対象外のホストシェルから実行するとno identity issuedになり、検証時に最も多い失敗です。
デコードした結果は次の形です(値は例)。
... | jq '{iss, sub, aud, ttl: (.exp - .iat), has_jti: has("jti")}'
{
"iss": "https://oidc-discovery.prod.example.com",
"sub": "spiffe://prod.example.com/ns/inference/sa/worker",
"aud": [
"https://api.anthropic.com"
],
"ttl": 300,
"has_jti": false
}ttlが300ならSPIREの既定の5分で、issuerの上限(既定1時間)に収まっています。has_jtiがtrueなら、前の節の単回使用の話がそのまま当てはまります。
issが発行者URL、subがワークロードのSPIFFE ID、audにhttps://api.anthropic.comが入っていれば、上のcURL例を実行します。成功するとsk-ant-oat01-で始まるaccess_tokenが返ります。ルールの検証に失敗すると、401のauthentication_error(Authentication failed)が返ります。
交換が失敗したときの切り分け
401は、どの検査で落ちても同じ文面で返ります。エラーの一覧はWIFリファレンスにあります。理由はClaude Consoleの認証履歴ページにだけ出ます。履歴を見られない場合は、デコードしたJWTを次の順に当てます。
SPIFFE連携の失敗を切り分ける
- 1
iss を発行者URLと1文字ずつ比べる
SPIRE側の
jwt_issuerが、登録したissuer_urlと末尾のスラッシュやポートまで一致しているかを見ます。SPIRE側で最も多い原因はこれです。 - 2
aud と sub をルールに当てる
audにhttps://api.anthropic.comが含まれることと、subがルールのsubject_prefixで始まることを確かめます。subject_prefixは大文字小文字を区別します。 - 3
iat と exp と時計を見る
iatが未来、またはexpが過去(どちらも30秒の猶予あり)なら拒否されます。ワークロードのホストの時計がずれていても同じ結果になります。 - 4
jwks の取得経路を確かめる
discoveryなら、
/.well-known/openid-configurationが公開HTTPSの443番ポートで開け、jwks_uriが解決できるかを見ます。取得先は、discovery_baseを設定していればそのURL、なければ発行者URLです。inlineなら、SPIREが鍵を回した後に登録を更新したかを見ます。 - 5
jti_reused が出ていないか履歴を見る
履歴に
jti_reusedが出ていれば、ファイルが更新される前に同じトークンで再交換しています。
ルールのスコープを絞る
SPIFFE IDのパス規則は運用者が自由に決めるため、subject_prefixは登録エントリで使っているパス構成に合わせます。よくある構成は、Kubernetesのspire-controller-managerがClusterSPIFFEIDリソースで自動生成するspiffe://<trust-domain>/ns/<namespace>/sa/<service-account>と、VM・ベアメタル向けのspiffe://<trust-domain>/host/<hostname>/<service>です。
subject_prefixをspiffe://prod.example.com/*にすると、trust domain内の全ワークロードが対象になります。audienceがなければ、無関係な相手向けに発行されたSVIDも通ります。なおmatchがaudienceだけのルールは、subject_prefix・claims・conditionのいずれもないので登録時に拒否されます。
- 1ワークロードに固定:
subject_prefixを末尾*なしの完全なSPIFFE IDにする - audienceを必ず設定: ルールに
audienceを必須にし、spiffe-helper(またはWorkload API呼び出し)にも同じ値を要求させ、別の依拠者向けに発行されたSVIDを拒否する - パスセグメントで絞る:
spiffe://prod.example.com/ns/inference/*のようにnamespace単位で許可し、1つのルールを広げるのではなくnamespaceごとに別ルール・別サービスアカウントを作る - trust domainごとに1 issuer: SPIREのtrust domainごとに署名鍵とOIDC Discovery Providerが異なるため、それぞれ別のfederation issuerとして登録し、対応するSPIFFE IDにルールを紐づける
まとめ
SPIREのjwt_issuerが登録したissuer_urlと一致していて、SVIDの更新がSDKの再交換に先行していれば、連携は通ります。subject_prefixとaudienceは、登録エントリのパス構成に合わせて絞ります。OktaのようなIdPと役割を切り分けたい場合は、ClaudeのWIFをOktaと連携する手順も参照してください。