Claude CodeセルフホストのJWT検証 — セッション身元を確かめる
セルフホスト環境のセッションはCLAUDE_CODE_SESSION_ACCESS_TOKENというJWTを持ちます。社内サービス側での検証手順とクレーム構造、よくある取り違えを確認します。
セルフホスト環境のセッションは自社ネットワークの内側で動くため、Claudeが社内サービスを直接呼び出せます。呼ばれる側のサービスは、そのリクエストが本当にセルフホスト環境のセッションから来たものか、どのユーザーやサービスIDがそのセッションを作ったのかを確認する手段が必要です。答えがCLAUDE_CODE_SESSION_ACCESS_TOKEN環境変数に入るJSON Web Token(JWT)です。セッションはこのトークンをcurl -H "Authorization: Bearer $CLAUDE_CODE_SESSION_ACCESS_TOKEN"のようにベアラー認証情報として提示します。サービス側はAnthropicが公開する鍵で署名を検証し、クレームを読みます。検証すべき6項目と、クレームの構造、社内サービスの実装まで確認します。
トークンが証明すること・証明しないこと
このJWTが証明するのは2点です。1つは、特定のEnvironmentの特定のセッションに対してAnthropicが発行したことです。もう1つは、そのセッションが組織内のユーザーによって作られたか、Claude Tagチャンネルのセッションのように組織のサービスアイデンティティによって作られたかです。証明しないのは、Runnerホスト上のどのプロセスがこのトークンを提示しているかです。トークンはセッション内の環境変数に置かれているため、Claudeが実行するどんなコードも、セッションが起動するどんなツールやMCPサーバーも読み取って提示できます。
ここから2つのことが導かれます。1つは、audクレームを自社のEnvironment ID(管理画面に表示されるccpool_...形式の値)と照合し、他組織のEnvironmentに発行されたトークンを拒否することです。もう1つは、トークンから派生させる認証情報を、そのセッション作成者が持つ権限全体ではなく、1つのコーディングセッションが持つべき範囲に絞ることです。
トークンの形式
CLAUDE_CODE_SESSION_ACCESS_TOKENの値はsk-ant-cc-というプレフィックスに続けて、標準的な3パートのJWTが並ぶ形式です。
sk-ant-cc-<base64url header>.<base64url payload>.<base64url signature>JWTライブラリに渡す前にプレフィックスを取り除きます。Anthropicがホストするクラウドセッションに発行されるトークンは代わりにsk-ant-si-というプレフィックスを持ち、別の鍵セットで署名されています。そのためsk-ant-cc-で始まらない値は必ず拒否します。署名アルゴリズムはP-256曲線上のECDSAであるES256で、トークンヘッダーのkidがJWKS内のどの鍵で署名したかを示します。
サービス側でトークンを検証する6ステップ
Anthropicは検証鍵を公開・無認証のエンドポイントで配布します。
https://api.anthropic.com/v1/code/.well-known/jwks.jsonレスポンスは標準的なJSON Web Key Setです。Anthropicは署名鍵を定期的にローテーションし、ローテーション前の鍵もその鍵で署名されたトークンが検証できる期間は残るため、単一の鍵を固定して使い回さないようにします。エンドポイントはCache-Control: public, max-age=300を返すので、鍵セットをキャッシュして5分ごとに再取得する構成で問題ありません。
受け取ったトークンごとに、次の6つを順に確認します。
- プレフィックスを確認する:
sk-ant-cc-で始まらない値は拒否し、プレフィックスを取り除きます - 署名を検証する: JWKSを取得し、トークンヘッダーの
kidに一致する鍵でES256署名を検証します。algヘッダーがES256でないトークンは拒否します。キャッシュ済みの鍵セットに無いkidが届いたら、拒否する前にJWKSを1回だけ再取得します。ローテーション直後は、キャッシュにまだ無い鍵で新しいトークンが署名されているためです - 発行者を検証する:
issが正確にccrでなければ拒否します - 自社Environmentに対するオーディエンスを検証する:
audクレームは配列です。ccpool_...形式の自社Environment IDを含まないトークンは拒否します。このチェックがトークンを自社Environmentに限定し、他組織に発行されたトークンを弾きます - ロールを検証する:
ccr:roleが正確にsession_workerでなければ拒否します。同じ鍵セットで署名される他のトークン(environment secret・Runnerトークン・work order)は異なるロールを持ちます - 有効期限を検証する:
expが過去なら拒否します。Anthropicはセッショントークンを既定4時間、最大8時間の有効期間で発行します。Runnerは期限前にトークンを更新してセッションへ渡すため、1つのセッションが生存期間中に複数の有効なトークンを提示することがあります
身元はactクレームに入っています。act.subはuser:<id>形式のAnthropicユーザーIDで、作成元のサーフェスが記録していればact.emailにメールアドレスも入ります。組織のサービスアイデンティティが作るセッション(Claude Tagチャンネルのセッションを含む)はagent:から始まるsubを持ちます。そのため判定は、識別クレームの有無ではなくact.subがuser:プレフィックスを持つかどうかで行います。
Node.jsとPythonで検証を実装する
上の6ステップをそのまま実装した例です。JWKSの取得・キャッシュ・kid選択を標準ライブラリに任せます。
import { createRemoteJWKSet, jwtVerify } from "jose";
const JWKS = createRemoteJWKSet(
new URL("https://api.anthropic.com/v1/code/.well-known/jwks.json")
);
const PREFIX = "sk-ant-cc-";
const EXPECTED_POOL_ID = "ccpool_...";
export async function verifySessionToken(raw: string) {
if (!raw.startsWith(PREFIX)) {
throw new Error("not a self-hosted runner session token");
}
const jwt = raw.slice(PREFIX.length);
const { payload } = await jwtVerify(jwt, JWKS, {
issuer: "ccr",
audience: EXPECTED_POOL_ID,
algorithms: ["ES256"],
});
if (payload["ccr:role"] !== "session_worker") {
throw new Error("token is not a session_worker token");
}
const act = payload.act as { email?: string; sub?: string };
return {
sessionId: payload["ccr:session_id"] as string,
poolId: payload["ccr:pool_id"] as string,
orgId: payload["ccr:org_id"] as string,
creatorEmail: act?.email,
creatorSub: act?.sub,
};
}import jwt
from jwt import PyJWKClient
JWKS_URL = "https://api.anthropic.com/v1/code/.well-known/jwks.json"
PREFIX = "sk-ant-cc-"
EXPECTED_POOL_ID = "ccpool_..."
jwks = PyJWKClient(JWKS_URL)
def verify_session_token(raw: str) -> dict:
if not raw.startswith(PREFIX):
raise ValueError("not a self-hosted runner session token")
token = raw.removeprefix(PREFIX)
signing_key = jwks.get_signing_key_from_jwt(token)
payload = jwt.decode(
token,
signing_key.key,
algorithms=["ES256"],
issuer="ccr",
audience=EXPECTED_POOL_ID,
)
if payload.get("ccr:role") != "session_worker":
raise ValueError("token is not a session_worker token")
act = payload.get("act") or {}
return {
"session_id": payload["ccr:session_id"],
"pool_id": payload["ccr:pool_id"],
"org_id": payload["ccr:org_id"],
"creator_email": act.get("email"),
"creator_sub": act.get("sub"),
}セッションの内側から検証する — decode-tokenサブコマンド
ラッパースクリプトはセッションの内側、Claudeが起動する前に動きます。JWTライブラリを呼ぶ代わりに、Runnerバイナリのself-hosted-runner decode-tokenサブコマンドが使えます。位置引数・CLAUDE_CODE_SESSION_ACCESS_TOKEN・パイプされた標準入力の順でトークンを読みます。プレフィックスを取り除き、JWKSエンドポイントに対して署名を検証し、有効期限をチェックしてクレームをJSONで出力します。このサブコマンドが行うのは署名と有効期限のチェックだけで、iss・aud・ccr:roleは確認しません。これらのクレームに依存する認可判断をラッパー側で行う場合は、出力されたJSONから明示的に読んで比較します。
次のコマンドは、SSOプロバイダーのsubject・メールアドレス・act.subの順で作成者の識別子を取り出します。
"$CLAUDE_RUNNER_CLAUDE_BIN" self-hosted-runner decode-token | jq -re '.act.attested_by.sub // .act.email // .act.sub'ラッパーはCLAUDE_RUNNER_CLAUDE_BINにRunner自身のバイナリへの絶対パスを受け取ります。PATH解決のclaudeではなくこのパスを使うと、Runner自身が使っているのと同じバイナリでデコードできます。jq -rではなくjq -reを使うと、クレームが欠落しているとき非ゼロ終了になります。-rだけだと文字列nullが出力されて終了コードは0のままなので、不正な値がそのまま下流に流れます。JWKSエンドポイントに到達できないオフライン検証に限り、decode-tokenへ--no-verifyを渡します。
クレーム一覧とactチェーン
検証に関わる主なクレームです。身元はccr:*名前空間とactチェーンから読みます。フラットなaccount_email・organization_uuid・account_uuidは、将来削除されうる後方互換の複製として扱います。
| クレーム | 内容 |
|---|---|
iss | 内容常にccr |
aud | 内容配列。セルフホスト環境のセッションではanthropic-apiと自社Environment ID(ccpool_...)の両方を含む。検証するのはEnvironment ID側 |
exp | 内容Unix時間での有効期限。既定4時間、最大8時間 |
ccr:role | 内容セッショントークンでは常にsession_worker |
ccr:session_id | 内容セッションID。subクレームの末尾と同じ値 |
ccr:pool_id | 内容自社Environment ID。audにも同じ値が入る |
ccr:org_id | 内容自社のAnthropic組織ID |
ccr:account_id | 内容セッション作成者のAnthropicアカウントID(act.subからuser:プレフィックスを除いた値) |
act | 内容RFC 8693の委任チェーン。下表を参照 |
actクレームは、セッションを作ったユーザーまたはサービスアイデンティティから、Runnerの登録を許可したEnvironmentの秘密鍵の作成者まで、委任の経路をすべて記録します。作成者が最も外側のアクターなので、act.subが直接その人を示します。
| パス | 内容 |
|---|---|
act.sub | 内容作成者のAnthropicユーザーID(user:<id>)。組織のサービスアイデンティティがセッションを作った場合はagent:<id> |
act.email | 内容作成時に記録されていれば作成者のメールアドレス。必須と見なさずact.subを主キーにする |
act.attested_by | 内容SSOプロバイダーによる作成者のアテステーション。act.attested_by.subはGoogleやOktaが発行したsubject |
act.act | 内容セッションをスポーンしたRunner。act.act.subはccr:runner:<runner_id> |
act.act.act | 内容Environment自体。act.act.act.subはccr:pool:<pool_id> |
act.act.act.act | 内容Runnerが登録に使ったenvironment secretの作成者。チェーンはここで終わる |
Claude Tagのチャンネルセッションのように組織のサービスアイデンティティが作るセッションは、act.subがagent:から始まります。この種のセッションはact.email・ccr:account_id・account_email・account_uuidを持ちません。ユーザー作成のセッションでも2つのメールクレームは任意です。CLIから起票したセッションはどちらも欠くことがあるため、メールではなくact.subまたはccr:account_idを身元のキーにします。
派生する認証情報のスコープを絞る
検証はオフラインで完結します。JWKSに対して検証が通ったトークンは、セッションがその後どうなっていようとexpまで有効なままで、Anthropicはセッショントークンの失効フィードを公開していません。トークンから派生させるものは、この前提に合わせて範囲を絞ります。
- 権限を絞る: 1つのコーディングセッションが必要とする読み書き権限だけを与え、作成者が他所で持つ管理権限までは与えません
- 寿命を絞る: 派生する認証情報の有効期限は、トークンの
exp以下に収めます - セッションとして監査する:
ccr:session_idとjtiを作成者の識別子と一緒に記録し、動作を特定のセッションまで追跡できるようにします
なおCLAUDE_CODE_SESSION_ACCESS_TOKENとは別に、モデル推論専用のCLAUDE_CODE_OAUTH_TOKENという短命トークンもセッションに渡ります。両者は用途が異なります。後者はOAuthトークンの失効・期限切れで扱う再ログインの文脈で登場するトークンで、混同しないようにします。
検証場所の使い分け早見表
| 検証場所 | 検証する内容 | 使うツール | 向いている用途 |
|---|---|---|---|
| 社内サービス側 | 検証する内容署名・iss・aud・ccr:role・expのフル検証 | 使うツールJWTライブラリ(jose・PyJWT等) | 向いている用途ネットワーク越しに届くリクエストの認可判断 |
| ラッパースクリプト内 | 検証する内容署名・expのみ(decode-token) | 使うツールself-hosted-runner decode-token | 向いている用途セッション起動前の認証情報発行 |
| 素の環境変数 | 検証する内容検証なし(Runnerが署名検証なしで事前抽出した値) | 使うツールCCR_SESSION_ACCOUNT_EMAIL等 | 向いている用途ラベリング用途のみ。認可判断には使わない |
よくあるつまずき
sk-ant-si-プレフィックスのトークンも受け付けてしまう: Anthropicホストのクラウドセッション用トークンで、セルフホスト環境のセッション用とは別の鍵セットで署名されています。sk-ant-cc-以外は必ず拒否します- JWKSの鍵を1つに固定する: Anthropicは署名鍵を定期的にローテーションします。未知の
kidが来たときにJWKSを再取得しないと、ローテーション直後の正当なトークンまで拒否してしまいます audをanthropic-api側で照合する:aud配列にはanthropic-apiも含まれますが、自社Environmentへの限定に使うのはccpool_...形式のEnvironment ID側ですCCR_SESSION_ACCOUNT_EMAILを認可判断に使う: この変数はRunnerが署名検証なしで抽出した値で、ラベリング用途に限られます。認可が絡む判断は必ずトークンを検証してからact.emailを読みますact.emailが必ず入っていると仮定する: 作成時のリクエストにメールが記録されていない場合や、CLIから起票したセッションでは欠けることがあります。act.subをキーにします
まとめ
CLAUDE_CODE_SESSION_ACCESS_TOKENは、社内サービスがセルフホスト環境のセッションを認可するための唯一の暗号学的な証拠です。プレフィックス・署名・iss・aud・ccr:role・expの6つを検証し、身元はactチェーンから読みます。トークンはセッション内の誰でも読める環境変数に置かれています。そこから発行する認証情報は、1つのコーディングセッションが必要とする範囲と寿命に絞ります。ラッパースクリプトや3種類のライフサイクルフックの実装はClaude Codeセルフホスト環境をラッパースクリプトとフックで拡張するにまとめています。