Claude Media
Claude CodeセルフホストのJWT検証 — セッション身元を確かめる

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. 1

    プレフィックスを確認する

    sk-ant-cc-で始まらない値は拒否し、始まる値からはプレフィックスを取り除きます。

  2. 2

    署名を検証する

    JWKSから、トークンヘッダーのkidに一致する鍵を選び、ES256署名を検証します。algヘッダーがES256でないトークンは拒否します。

    キャッシュ済みの鍵セットに無いkidが届いたら、拒否する前にJWKSを1回だけ再取得します。ローテーション直後は、キャッシュにまだ無い鍵で新しいトークンが署名されているためです。

  3. 3

    発行者を検証する

    issが正確にccrでなければ拒否します。

  4. 4

    自社Environmentへのオーディエンスを検証する

    audクレームは配列です。ccpool_...形式の自社Environment IDを含まないトークンは拒否します。このチェックがトークンを自社Environmentに限定し、他組織に発行されたトークンを弾きます。

  5. 5

    ロールを検証する

    ccr:roleが正確にsession_workerでなければ拒否します。同じ鍵セットで署名される他のトークン(environment secret・Runnerトークン・work order)は異なるロールを持ちます。

  6. 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か所を単純な文字列比較でつなげます。

作成元によって、読めるクレームが変わります。

くらべる

ユーザー作成とサービスアイデンティティ作成

act.sub = user:<id>

ユーザーが作ったセッション

ccr:account_idとaccount_uuidが入ります。act.emailとaccount_emailは作成時のリクエストにメールがあるときだけ入るため、CLIから起票したセッションでは両方欠けることがあります。

act.sub = agent:<id>

サービスアイデンティティが作ったセッション

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つのコーディングセッションの範囲と寿命に収めます。

この記事を共有:XはてブLinkedIn