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つを順に確認します。
受け取ったトークンごとの6つの検証
- 1
プレフィックスを確認する
sk-ant-cc-で始まらない値は拒否し、始まる値からはプレフィックスを取り除きます。 - 2
署名を検証する
JWKSから、トークンヘッダーの
kidに一致する鍵を選び、ES256署名を検証します。algヘッダーがES256でないトークンは拒否します。キャッシュ済みの鍵セットに無い
kidが届いたら、拒否する前にJWKSを1回だけ再取得します。ローテーション直後は、キャッシュにまだ無い鍵で新しいトークンが署名されているためです。 - 3
発行者を検証する
issが正確にccrでなければ拒否します。 - 4
自社Environmentへのオーディエンスを検証する
audクレームは配列です。ccpool_...形式の自社Environment IDを含まないトークンは拒否します。このチェックがトークンを自社Environmentに限定し、他組織に発行されたトークンを弾きます。 - 5
ロールを検証する
ccr:roleが正確にsession_workerでなければ拒否します。同じ鍵セットで署名される他のトークン(environment secret・Runnerトークン・work order)は異なるロールを持ちます。 - 6
有効期限を検証する
expが過去なら拒否します。セッショントークンの有効期間は既定4時間、最大8時間です。Runnerは期限前にトークンを更新し、更新後にClaudeが起動するサブプロセスは新しい値を引き継ぎます。1つのセッションが生存期間中に複数の有効なトークンを提示することがあります。
身元はactクレームに入っています。act.subはuser:<id>形式のAnthropicユーザーIDで、作成元のサーフェスが記録していればact.emailにメールアドレスも入ります。組織のサービスアイデンティティが作るセッション(Claude Tagチャンネルのセッションを含む)はagent:から始まるsubを持ちます。そのため判定は、識別クレームの有無ではなくact.subがuser:プレフィックスを持つかどうかで行います。この「身元を読む」工程は、6つの検証に続く7つ目の手順にあたります。
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から明示的に読んで比較します。
次のコマンドは、メールアドレス、なければact.subの順で作成者の識別子を取り出します。
"$CLAUDE_RUNNER_CLAUDE_BIN" self-hosted-runner decode-token | jq -re '.act.email // .act.sub'ラッパーはCLAUDE_RUNNER_CLAUDE_BINにRunner自身のバイナリへの絶対パスを受け取ります。PATH解決のclaudeではなくこのパスを使うと、Runner自身が使っているのと同じバイナリでデコードできます。JWKSエンドポイントに到達できないオフライン検証に限り、decode-tokenへ--no-verifyを渡します。
手元のclaudeでdecode-tokenの挙動を試す
Claude Code v2.1.287のバイナリがあれば、ネットワークにもAnthropicアカウントにも触れずに挙動を試せます。--no-verifyを付け、自分で組み立てたダミーのトークン(署名部分はでたらめ)を渡します。claude self-hosted-runner decode-token --helpには、既定で署名とexp・nbfを検証すると書かれています。nbfは60秒のずれを許容します。成功時はverified (kid=…, sig+exp)を標準エラーへ出します。--header、--no-check-expiry、--api-urlも選べます。次の出力はv2.1.287で得たものです。
claude self-hosted-runner decode-token --no-verify "$DUMMY_TOKEN"{
"iss": "ccr",
"sub": "ccr:session:session_demo",
"aud": [
"anthropic-api",
"ccpool_demo"
],
"exp": 1790000000,
"ccr:role": "session_worker",
"ccr:session_id": "session_demo",
"ccr:pool_id": "ccpool_demo",
"act": { "sub": "agent:svc_demo" }
}この出力と周辺の挙動から分かることが3つあります。
jq -reとjq -rの差: ダミーのactにはemailが無いので、jq -r '.act.email'はnullという文字列を出し、終了コード0でした。jq -reでは同じnullを出して終了コード1になりました。パイプの末尾がjq -reなら、欠けたクレームでラッパーが止まりますsk-ant-si-も同じように剥がされる: 同じダミーのプレフィックスをsk-ant-si-に変えてもクレームが出力されました。--helpはsk-ant-cc-とsk-ant-si-のどちらのプレフィックスも自動で取り除くと説明しています。クラウドセッション用トークンの拒否は、サービス側のプレフィックス確認(手順1)の仕事になります- 形式が違う入力は即エラー:
garbageのような値は即エラーです。メッセージはnot a JWT — expected 3 dot-separated base64url segmentsで、終了コード1になります
ダミーのact.subはagent:で始まる値にしてあります。ユーザー作成のセッションならuser:<id>になり、user:だけを通したいラッパーは次の書き方になります。
CREATOR_SUB=$("$CLAUDE_RUNNER_CLAUDE_BIN" self-hosted-runner decode-token \
| jq -re '.act.sub // "" | select(startswith("user:"))') \
|| { echo "no human creator" >&2; exit 1; }agent:のセッションはここで弾かれます。Claude Tagのようなサービスアイデンティティ起点のセッションも同じ環境で動かすなら、デフォルトの認証情報へ落とすのか拒否するのかを、ラッパー側で明示的に決める必要があります。
ラッパーが読めるのは、起動時点のトークンだけです。更新後の値は子プロセスの標準入力経由で届くので、長時間動くセッションの途中でラッパーが再デコードしても、新しいトークンは見えません。ラッパーとライフサイクルフックの全体像はClaude Codeセルフホスト環境をラッパースクリプトとフックで拡張するで扱っています。
クレーム一覧とactチェーン
検証に関わる主なクレームです。身元はccr:*名前空間とactチェーンから読みます。フラットなaccount_email・organization_uuid・account_uuidは、将来削除されうる後方互換の複製として扱います。
| クレーム | 内容 |
|---|---|
iss | 内容常にccr |
sub | 内容ccr:session:<session_id> |
aud | 内容配列。セルフホスト環境のセッションではanthropic-apiと自社Environment ID(ccpool_...)の両方を含む。検証するのはEnvironment ID側 |
exp | 内容Unix時間での有効期限。既定4時間、最大8時間 |
jti | 内容トークンごとに一意の識別子 |
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:プレフィックスを除いたuser_...形式の値) |
act | 内容RFC 8693の委任チェーン。下表を参照 |
actクレームは、セッションを作ったユーザーまたはサービスアイデンティティから、Runnerの登録に使ったenvironment secretの作成者まで、委任の経路をすべて記録します。作成者が最も外側のアクターなので、act.subが直接その人を示します。
| パス | 内容 |
|---|---|
act.sub | 内容作成者のAnthropicユーザーID(user:<id>)。組織のサービスアイデンティティがセッションを作った場合はagent:<id> |
act.email | 内容作成時に記録されていれば作成者のメールアドレス。必須と見なさずact.subを主キーにする |
act.attested_by | 内容上流のIDプロバイダーによる作成者の証明用に予約された枠。存在しない前提で扱い、依存しない |
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の作成者。チェーンはここで終わる |
ccr:account_idは、spawn-runnerフックのCLAUDE_RUNNER_ACCOUNT_ID、Runnerの--lock-to-accountフラグが受け取るIDと同じ文字列です。3か所を単純な文字列比較でつなげます。
作成元によって、読めるクレームが変わります。
ユーザー作成とサービスアイデンティティ作成
ユーザーが作ったセッション
ccr:account_idとaccount_uuidが入ります。act.emailとaccount_emailは作成時のリクエストにメールがあるときだけ入るため、CLIから起票したセッションでは両方欠けることがあります。
サービスアイデンティティが作ったセッション
Claude Tagのチャンネルセッションがこちらです。act.email・ccr:account_id・account_email・account_uuidはすべて持ちません。
派生する認証情報のスコープを絞る
検証はオフラインで完結します。JWKSに対して検証が通ったトークンは、セッションがその後どうなっていようとexpまで有効なままで、Anthropicはセッショントークンの失効フィードを公開していません。トークンから派生させるものは、この前提に合わせて範囲を絞ります。
派生させる認証情報の3つの絞り方
権限を絞る
1つのコーディングセッションが必要とする読み書き権限だけを与えます。作成者が他所で持つ管理権限は含めません。
寿命を絞る
派生する認証情報の有効期限は、トークンの
exp以下に収めます。セッションとして監査する
ccr:session_idとjtiを作成者の識別子と一緒に記録し、動作を特定のセッションまで追跡できるようにします。
なおCLAUDE_CODE_SESSION_ACCESS_TOKENとは別に、CLAUDE_CODE_OAUTH_TOKENという短命のトークンもセッションに渡ります。用途はモデル推論とファイルアップロードに限られ、有効期間は約30分です。Runnerが期限前に再発行します。同名のCLAUDE_CODE_OAUTH_TOKENは、CIでclaude setup-tokenが発行する長期トークンの設定先にも使われます。OAuthトークンの失効・期限切れで扱うのはこちらです。セッションに渡る短命のものとは別物です。
検証場所の使い分け早見表
| 検証場所 | 検証する内容 | 使うツール | 向いている用途 |
|---|---|---|---|
| 社内サービス側 | 検証する内容署名・iss・aud・ccr:role・expのフル検証 | 使うツールJWTライブラリ(jose・PyJWT等) | 向いている用途ネットワーク越しに届くリクエストの認可判断 |
| ラッパースクリプト内 | 検証する内容署名・expのみ(decode-token) | 使うツールself-hosted-runner decode-token | 向いている用途セッション起動前の認証情報発行 |
| 素の環境変数(セッション内) | 検証する内容検証なし(Runnerが署名検証なしで事前抽出した値) | 使うツールCCR_SESSION_ACCOUNT_EMAIL | 向いている用途コミットトレーラーなどのラベリング用途のみ。認可判断には使わない |
spawn-runnerフック(オーケストレーター側) | 検証する内容検証なし。work orderの署名を検証せず、Anthropicとの接続を信頼する | 使うツールCLAUDE_RUNNER_ACCOUNT_EMAIL・CLAUDE_RUNNER_ACCOUNT_ID | 向いている用途Runnerが存在する前のマシンイメージ選択など |
まとめ
認可の判断はサービス側のフル検証で行い、ラッパーのdecode-tokenは作成者のラベル付けと認証情報発行の入口に留めます。身元のキーはメールではなくact.subかccr:account_idです。トークンはセッション内のコードなら誰でも読めるため、派生させる認証情報は1つのコーディングセッションの範囲と寿命に収めます。