Claude Code 自托管环境的身份接入与验证:identity 与 testing 两页说明的步骤
一、这件事真正卡住的是什么
自托管环境(self-hosted environment)的意思是,Claude Code on the web 的会话不再跑在 Anthropic 的基础设施上,而是跑在你自己运维的机器上。带来的直接好处也很直白:会话在你的网络内部,Claude 可以直接去调你的内网服务。
麻烦也从这里开始。你的内网服务收到一个请求,凭什么相信它来自本环境的一个 Claude Code 会话?又凭什么知道这个会话是谁开的、该给多大权限?
官方文档给的答案是一枚签名的 JWT。自托管环境里的每个会话都会在 CLAUDE_CODE_SESSION_ACCESS_TOKEN 这个环境变量里拿到它,会话把它当普通 bearer 凭据用,文档举的例子就是会话里跑的脚本这样调你的服务:
curl -H "Authorization: Bearer $CLAUDE_CODE_SESSION_ACCESS_TOKEN" ...
Anthropic 负责签名,并在一个公开的 JWKS 端点上发布验签公钥。你的服务去拉公钥、验签、读 claims,再决定放行到什么程度。这篇要落到的就是:这枚 token 里有哪些字段、官方要求你按什么顺序检查、以及配完之后怎么验证确实配对了。
二、前置条件(这一段别跳)
能力可用性:文档在 identity、testing、reference 三页开头都挂了同一条注记——自托管环境处于 public beta,且限 Team 与 Enterprise 计划,由 Owner 或 admin 在 claude.ai 的 Cloud environments 管理页打开 Allow self-hosted environments。开关没打开时,创建环境的 API 调用直接返回 403 permission_error,文案是 self-hosted runners are disabled by your organization's policy。这条同时也是「开关到底开没开」的判定动作。
你需要先拿到环境 ID:形如 ccpool_...,在管理页上该环境的详情里能看到,创建环境的接口也会返回。后面验签的 aud 检查全靠它。
运行 runner 的主机:reference 页写明 runner 与 orchestrator 都跑在 Linux 或 macOS 主机上,/workspace、~/.claude 这些默认值是按这两个平台设的。关于 Windows,文档在 --base-dir 那一行写明「Windows 不是受支持的 runner 主机」,且该参数在 Windows 上没有默认值——不传 flag、也不设 SELF_HOSTED_RUNNER_BASE_DIR,runner 在启动阶段就会退出。
Windows 这侧还剩什么:跑派发脚本和存 OAuth 凭据这一端,文档是提到 Windows 的——CI 认证那一节写明凭据在 macOS 上进 OS keychain,在 Linux 和 Windows 上落在 ~/.claude/.credentials.json。但要注意,官方给的捕获 hook 与测试脚本都是 POSIX shell / bash,且依赖 jq;PowerShell 版本官方文档没有提供。所以 Windows 侧要么在 WSL 或 Git Bash 这类能跑 sh 的环境里执行,要么自己按语义重写,别指望复制粘贴就能跑。
版本下限:派发用的 --environment 与 --ref 两个 flag 要求跑脚本的机器上是 Claude Code v2.1.224 或更高,文档写明 runner 本身也是同一条下限。
三、服务端怎么验:官方给的七步
先认前缀和格式
CLAUDE_CODE_SESSION_ACCESS_TOKEN 的值是 sk-ant-cc- 前缀,后面接一个标准的三段式 JWT:
sk-ant-cc-<base64url header>.<base64url payload>.<base64url signature>
交给 JWT 库之前要把前缀剥掉。这里有个容易被忽略的分叉:Anthropic 托管的云会话拿到的 token 是 sk-ant-si- 前缀,由另一套密钥签名,所以文档要求直接拒绝任何不以 sk-ant-cc- 开头的值。签名算法是 ES256,即 P-256 曲线上的 ECDSA 配 SHA-256,header 里的 kid 指明用了 JWKS 中的哪把钥匙。
验签公钥在这个公开、免认证的端点上:
https://api.anthropic.com/v1/code/.well-known/jwks.json
文档说 Anthropic 会周期性轮换签名密钥,轮换前的旧钥匙会在集合里保留足够长的时间,让它签过的 token 仍能验通过,所以不要把单把密钥写死。该端点返回 Cache-Control: public, max-age=300,按文档说法缓存下来、每五分钟重取一次是安全的。至于轮换的具体周期是多久,官方文档没有说明这一点。
七步检查
identity 页把服务端的验证拆成七个步骤,逐条是:
| 顺序 | 检查什么 | 判定 |
|---|---|---|
| 1 | 前缀 | 不以 sk-ant-cc- 开头就拒,然后剥掉前缀 |
| 2 | 签名 | 按 kid 选钥匙验 ES256;alg 不是 ES256 就拒 |
| 3 | 签发方 | iss 不等于 ccr 就拒 |
| 4 | 受众 | aud 是数组,不包含你的 ccpool_... 就拒 |
| 5 | 角色 | ccr:role 不等于 session_worker 就拒 |
| 6 | 过期 | exp 已过就拒 |
| 7 | 读身份 | 从 act 链里取创建者 |
几处容易踩的地方值得单独说:
第 2 步的 kid 未命中。文档要求:如果来了个 kid 不在你缓存的密钥集里,先重取一次 JWKS 再决定拒不拒——轮换之后的新 token 用的是你缓存里还没有的钥匙。
第 4 步是整套检查的重心。aud 里总是含有 anthropic-api,文档明确要求你验的是环境 ID 而不是它。这一步才是把 token 锁死在你自己环境上、拒掉别的组织的 token 的地方。同一个值也会出现在 ccr:pool_id claim 里。
第 5 步不是多余的。同一套密钥还签发自托管环境的其它 token——环境 secret、runner token、work order 都由它签,只是 ccr:role 不同。少这一步,别的角色的 token 也能进你的服务。
第 6 步会带出一个反直觉的现象。文档给会话 token 设了一个默认有效期,另有一个更长的硬上限(具体时长见官方文档 claims 表里 exp 那一行,且随版本可能调整);runner 会在过期前刷新并把新值推给会话,之后 Claude 起的子进程会继承新值。结果就是:同一个会话在生命周期里会向你的服务出示好几个各不相同的有效 token,服务端做会话级去重时别按 token 本身做键。
第 7 步的身份在 act 里。act.sub 是创建者的 Anthropic 用户 ID,形式是 user:<id>;act.email 只在创建时确实记录到邮箱的情况下才有。用组织 service key 创建的会话没有用户身份,所以文档要求判断「是否用户创建」时去看 act.sub 有没有 user: 前缀,而不是去测身份类 claim 是否缺失。
代码怎么写
Node.js 侧文档给的是 jose,它自带 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,
};
}
Python 侧文档给的是 PyJWT 加它自带的 PyJWKClient,顺序一一对应:先按前缀拒、removeprefix 剥掉,再 jwt.decode(..., algorithms=["ES256"], issuer="ccr", audience=EXPECTED_POOL_ID),最后单独比对 payload.get("ccr:role") != "session_worker"。两个版本都把 ccr:role 放在库外面手工比。
四、会话内怎么验:decode-token
另一条路径是在会话里面验。wrapper script 在 Claude 启动之前运行,文档说它不必去引 JWT 库,可以直接调 runner 二进制的 self-hosted-runner decode-token 子命令。它按位置参数、CLAUDE_CODE_SESSION_ACCESS_TOKEN、管道 stdin 的顺序取 token,剥前缀、对着 JWKS 端点验签、检查过期,然后把 claims 以 JSON 打印出来。
文档提取创建者身份的例子是这一条:
"$CLAUDE_RUNNER_CLAUDE_BIN" self-hosted-runner decode-token | jq -re '.act.attested_by.sub // .act.email // .act.sub'
三点提示,都是文档明写的:
- 用
CLAUDE_RUNNER_CLAUDE_BIN里给的绝对路径,别用 PATH 解析出来的claude,这样解码走的是 runner 自己那份二进制。 - 用
jq -re而不是jq -r。只用-r时,claim 缺失会打印字面量null并且退出码为零,坏值就这么静悄悄传下去了。 --no-verify只在 JWKS 端点不可达、纯离线看一眼的场合用。
这里有个必须记住的边界:decode-token 只做签名与过期两项检查,不检查 iss、aud、ccr:role。如果你 wrapper 里的鉴权决策依赖这三个 claim,得自己从打印出来的 JSON 里读出来显式比对。
五、边界:这枚 token 证明什么、不证明什么
这一节是 identity 页写得最克制、也最该照抄的部分。
它证明的:Anthropic 为某个环境里的某个具体会话签发了这枚 token,以及这个会话是怎么创建的——由你组织里的某个用户创建,还是用组织 service key 创建。
它不证明的:出示它的是 runner 主机上的哪个进程。token 就放在会话内的一个环境变量里,Claude 跑的任何代码、会话起的任何工具或 MCP server 都能读到并出示它。
由此文档给了两条要求:一是 aud 必须比对你自己的环境 ID;二是你从 token 换出来的内部凭据,要按「一个编码会话应该能做什么」来限权,而不是按「创建者本人在别处拥有什么权限」来限权——能力上只给编码任务需要的读写,生命周期绑到 token 的 exp 或更短,审计上把 ccr:session_id 与 jti 连同用户身份一起记,好把动作追回到具体会话。
验证是离线的。文档写得很明白:一枚验通过的 token 在 exp 之前一直有效,无论这期间会话发生了什么;Anthropic 不发布会话 token 的吊销 feed。派生凭据的有效期要照这个前提来定。
邮箱 claim 不可依赖。service key 创建的会话会省略 act.email、ccr:account_id、account_email、account_uuid;即便是用户创建的会话,两个邮箱 claim 也是可选的——只有创建请求的凭据带邮箱时才记录,从 CLI 派发的会话可能两个都没有。所以身份要挂在 act.sub 或 ccr:account_id 上。做 SSO 映射时文档建议优先用 act.attested_by.sub,也就是你的身份提供商签发的 subject。
别用那几个扁平 claim。account_email、organization_uuid、account_uuid 是向后兼容的重复项,文档说它们可能被移除,身份要从 ccr:* 命名空间和 act 链里读。
两个不验签的旁路。spawn-runner hook 在 orchestrator 侧通过 CLAUDE_RUNNER_ACCOUNT_EMAIL、CLAUDE_RUNNER_ACCOUNT_ID 拿到创建者身份;wrapper 在会话内拿到 CCR_SESSION_ACCOUNT_EMAIL。文档对后者的定性很直接——它是未经签名验证从 token 里预抽出来的,适合打标签(例如 commit trailer),不适合做鉴权决策。
六、怎么验证配对了
identity 页只讲验证逻辑,真要确认「整条链路通了」,得配合 testing 页那套端到端回路。它的思路是:在测试 runner 上装一个 Stop hook,把每一轮的最终回复写到本地文件,脚本从文件里读,于是整个回路只有两次派发真正调了 Anthropic 的接口。
Stop hook 从 stdin JSON 里拿 last_assistant_message,追加写到 $E2E_REPLY_DIR/<session_id>.txt:
#!/bin/sh
[ -n "${E2E_REPLY_DIR:-}" ] && [ -d "$E2E_REPLY_DIR" ] || exit 0
sid=$(printf '%s' "${CLAUDE_CODE_REMOTE_SESSION_ID:-}" | sed 's/^cse_/session_/')
[ -n "$sid" ] || exit 0
jq -r '.last_assistant_message // empty' >> "$E2E_REPLY_DIR/$sid.txt" 2>/dev/null
exit 0
那行 sed 不是装饰:文档注释写明 CLAUDE_CODE_REMOTE_SESSION_ID 导出的是 cse_... 形式,而派发 CLI 打印的 session id 是 session_... 形式,同一个 id,前缀不同。不做这次替换,脚本就永远等不到那个文件。
两个部署前提也是文档明说的:hook 必须在 runner 启动之前装好,因为 runner 在启动时对 ~/.claude/ 做一次快照,给运行中的 runner 加 hook 要重启才生效;E2E_REPLY_DIR 要导出给 runner 进程,变量没设或目录不存在时 hook 直接空转。文档还提醒这个 hook 只装在测试环境的 runner 上。
回路本身四步:用 claude -p "<prompt>" --environment <environment-id> --output-format json 在测试环境上建会话(要在 git checkout 目录里跑,CLI 才能从 origin remote 自动识别仓库),它打印一行含 session_id 的 JSON 就退出,不等回复;然后等 $E2E_REPLY_DIR/<session_id>.txt 出现约定的哨兵串;再用 claude -p "<message>" --cloud <session_id> --output-format json 发一条跟进;再等第二轮回复。可选的 --ref <branch> 让会话的 checkout 基于指定 ref 而不是本地 HEAD。
--environment 自身的限制文档列得很细,配之前先看一眼:它优先于 remote.defaultEnvironmentId 设置;不支持 --output-format stream-json;不能和 --resume、--continue、--teleport、--session-id、--init-only 这些恢复、附着或预配置会话的 flag 一起用。
认证这块还有一处硬约束:claude -p ... --environment 和 claude -p ... --cloud 都只认 claude.ai 的 OAuth token,API key(sk-ant-xxxxx 那种)不被接受。文档同时写明今天没有面向临时 CI runner 的长期 token——授予远程会话控制的 scope user:sessions:claude_code 在服务端就有上限,claude setup-token 铸的那种只覆盖推理的 token 不管用,环境 secret 也不行,它只授权 runner 注册进环境。要在临时 runner 上落登录态,文档指的是设 CLAUDE_CODE_OAUTH_REFRESH_TOKEN 与 CLAUDE_CODE_OAUTH_SCOPES,让 claude auth login 免浏览器完成交换。
回到身份这一侧,配对了的信号可以拆成三个各自独立的:wrapper 里那条 decode-token 管道能打印出非空的身份值(用了 jq -re,缺 claim 会非零退出);你的服务端对一枚真实 token 跑完七步不抛错,且拿到的 poolId 与管理页上的 ccpool_... 逐字相同;端到端脚本两轮哨兵串都命中。三个信号分别覆盖会话内、服务端、整条派发链路,缺一个就说明问题出在对应那一段。
以上命令与配置均按官方文档中的参数语义组合,未经实测,以官方文档与 --help 的实际输出为准;reference 页也提示用 claude self-hosted-runner --help 取你所装版本上的权威列表。
本文依据 Claude Code 官方文档(code.claude.com/docs)于 2026-08-17 的公开内容整理。
该产品闭源,本文只复述官方文档写明的机制,不推断其内部实现;
我们没有对文中涉及的功能做过实测,因此不涉及界面外观、操作手感与运行速度的任何描述。
该产品迭代频繁,文中涉及的命令、配置项与默认值随版本变动,请以官方文档最新内容为准。
本文不涉及价格、额度与限流的具体数值,相关信息请以官方定价与用量说明页为准。
安全与合规相关做法请结合自身环境评估,本文不构成安全方案建议。