Verify session identity in self-hosted environments
Validate the CLAUDE_CODE_SESSION_ACCESS_TOKEN JWT so internal services can trust, and attribute, requests from self-hosted Claude Code sessions.
When a session runs in your self-hosted environment, Claude can call your internal services directly. Those services will reasonably ask two questions: did this request really come from a Claude Code session in our environment, and who started that session? The session access token answers both.
Each self-hosted session gets a signed JWT in the CLAUDE_CODE_SESSION_ACCESS_TOKEN environment variable. Anything in the session can present it as a bearer token, for example:
curl -fsS https://feature-flags.internal.acme.dev/v1/flags \
-H "Authorization: Bearer $CLAUDE_CODE_SESSION_ACCESS_TOKEN"
Anthropic signs the token and publishes the public keys. Your service verifies the signature, checks a handful of claims and decides what to allow.
What the token does and doesn't prove
A token that passes verification proves:
- Anthropic issued it for a particular session in a particular environment.
- How the session was created: by a user in your organisation, or by your organisation's service identity (which is how Claude Tag channel sessions start).
It does not prove which process on the runner is presenting it. The token sits in an environment variable, so any command Claude runs, and any tool or MCP server the session starts, can read it and send it.
That leads to two rules for your services:
- Always check
audagainst your own environment ID (theccpool_...value on the Cloud environments admin page). This rejects tokens from every other organisation's environments. - Grant what a single coding session needs, not everything the creator could do if they logged in themselves. See scoping derived credentials.
Token format
The variable's value is sk-ant-cc- followed by an ordinary compact JWT:
sk-ant-cc-<header>.<payload>.<signature>
Strip the prefix before handing it to a JWT library. Anthropic-hosted cloud sessions use an sk-ant-si- prefix and a different key set, so reject anything that doesn't start with sk-ant-cc-.
Tokens are signed with ES256 (ECDSA, P-256, SHA-256). The header's kid says which key in the set signed it.
Verifying in your service
The keys live at a public, unauthenticated JWKS endpoint:
https://api.anthropic.com/v1/code/.well-known/jwks.json
Keys rotate periodically, and old keys stay published long enough for tokens they signed to remain verifiable, so never hard-code a single key. The response carries Cache-Control: public, max-age=300; caching and refetching every five minutes is fine.
Run these checks in order:
| Step | Check | Reject when |
|---|---|---|
| 1 | Prefix | The value doesn't start with sk-ant-cc- (then strip it) |
| 2 | Signature | No JWKS key matches kid, the ES256 signature fails, or alg isn't ES256. On an unknown kid, refetch the JWKS once before rejecting, since a fresh rotation may not be in your cache yet |
| 3 | Issuer | iss is not exactly ccr |
| 4 | Audience | The aud array doesn't contain your ccpool_... environment ID |
| 5 | Role | ccr:role is not exactly session_worker. Environment secrets, runner tokens and work orders are signed by the same keys but carry other roles |
| 6 | Expiry | exp is in the past |
| 7 | Identity | Read act.sub; treat the session as user-created only if it starts with user: |
On expiry: session tokens live four hours by default, eight at most. The runner refreshes the token before it expires and pushes the new value into the session, so processes Claude starts afterwards get the fresh one. Expect one session to show your service several different valid tokens over its life.
On identity: act.sub is user:<id> for people and agent:<id> for your organisation's service identity, including Claude Tag channel sessions. Test the prefix rather than inferring from missing claims. act.email is present when the creating surface recorded an email.
Example: Express middleware with jose
This middleware protects an internal Node service and attaches the verified identity to the request. jose handles fetching, caching and kid selection.
import type { Request, Response, NextFunction } from "express";
import { createRemoteJWKSet, jwtVerify } from "jose";
const keys = createRemoteJWKSet(
new URL("https://api.anthropic.com/v1/code/.well-known/jwks.json"),
);
const ENVIRONMENT_ID = process.env.CLAUDE_POOL_ID!; // e.g. ccpool_abc123
export async function requireClaudeSession(req: Request, res: Response, next: NextFunction) {
const header = req.get("authorization") ?? "";
const raw = header.replace(/^Bearer\s+/i, "");
if (!raw.startsWith("sk-ant-cc-")) return res.status(401).send("unexpected token type");
try {
const { payload } = await jwtVerify(raw.slice("sk-ant-cc-".length), keys, {
algorithms: ["ES256"],
issuer: "ccr",
audience: ENVIRONMENT_ID,
});
if (payload["ccr:role"] !== "session_worker") return res.status(403).send("wrong role");
const act = (payload.act ?? {}) as { sub?: string; email?: string };
res.locals.claude = {
sessionId: payload["ccr:session_id"],
tokenId: payload.jti,
creator: act.sub,
isUser: act.sub?.startsWith("user:") ?? false,
email: act.email,
expiresAt: payload.exp,
};
next();
} catch {
res.status(401).send("invalid session token");
}
}
Example: FastAPI dependency with PyJWT
The same checks in Python, using PyJWT and its JWKS client:
import os
import jwt
from jwt import PyJWKClient
from fastapi import Header, HTTPException
JWKS = PyJWKClient("https://api.anthropic.com/v1/code/.well-known/jwks.json")
ENVIRONMENT_ID = os.environ["CLAUDE_POOL_ID"] # ccpool_...
PREFIX = "sk-ant-cc-"
def claude_session(authorization: str = Header(...)) -> dict:
raw = authorization.removeprefix("Bearer ").strip()
if not raw.startswith(PREFIX):
raise HTTPException(401, "unexpected token type")
token = raw[len(PREFIX):]
try:
key = JWKS.get_signing_key_from_jwt(token).key
claims = jwt.decode(
token, key, algorithms=["ES256"], issuer="ccr", audience=ENVIRONMENT_ID
)
except jwt.PyJWTError as exc:
raise HTTPException(401, f"invalid session token: {exc}")
if claims.get("ccr:role") != "session_worker":
raise HTTPException(403, "wrong role")
act = claims.get("act") or {}
return {
"session_id": claims["ccr:session_id"],
"jti": claims["jti"],
"creator": act.get("sub"),
"is_user": str(act.get("sub", "")).startswith("user:"),
"email": act.get("email"),
}
Verifying inside the session
Wrapper scripts run inside the session before Claude starts. Rather than pulling in a JWT library, they can use the runner binary's self-hosted-runner decode-token subcommand. It takes the token from, in order of preference, a positional argument, CLAUDE_CODE_SESSION_ACCESS_TOKEN, or piped stdin. It strips the prefix, verifies the signature against the JWKS, checks expiry and prints the claims as JSON.
It does not check iss, aud or ccr:role. If your wrapper's decision depends on them, compare them yourself:
claims=$("$CLAUDE_RUNNER_CLAUDE_BIN" self-hosted-runner decode-token) || exit 1
role=$(jq -re '."ccr:role"' <<<"$claims") || exit 1
[ "$role" = "session_worker" ] || { echo "unexpected role: $role" >&2; exit 1; }
jq -e --arg env "$EXPECTED_POOL_ID" '.aud | index($env)' <<<"$claims" >/dev/null || exit 1
creator=$(jq -re '.act.email // .act.sub' <<<"$claims") || exit 1
echo "session started by $creator"
Three details matter here:
- Use
CLAUDE_RUNNER_CLAUDE_BIN, the absolute path to the runner's own binary that wrappers receive, rather than whicheverclaudeis onPATH. - Use
jq -re, notjq -r. With-ralone a missing claim prints the stringnulland exits 0, quietly passing a bad value along. --no-verifyskips signature checking. Use it only for offline inspection when the JWKS endpoint is unreachable.
Claims reference
Read identity from the ccr:* claims and the act chain. Ignore claims you don't recognise; tokens may carry extras.
| Claim | Type | Meaning |
|---|---|---|
iss | string | Always ccr |
sub | string | ccr:session:<session_id> |
aud | string array | Always includes anthropic-api; for self-hosted sessions also your ccpool_... ID. Check the environment ID, not anthropic-api |
exp | number | Expiry, Unix seconds. Four hours by default, eight maximum |
iat | number | Issued at, Unix seconds |
jti | string | Unique token ID |
ccr:role | string | session_worker for session tokens |
ccr:session_id | string | Session ID, matching the end of sub |
ccr:pool_id | string | Your environment ID, matching the value in aud |
ccr:org_id | string | Your Anthropic organisation ID |
ccr:account_id | string | The creating user's account ID: act.sub without user:, a user_... value. Equal to the spawn-runner hook's CLAUDE_RUNNER_ACCOUNT_ID and to what --lock-to-account accepts |
account_email | string | Legacy duplicate of act.email; absent whenever that is |
organization_uuid | string | Legacy: organisation UUID |
account_uuid | string | Legacy: creating user's account UUID |
act | object | RFC 8693 delegation chain (below) |
The flat account_email, organization_uuid and account_uuid claims exist for backward compatibility and may disappear, so don't build on them.
Sessions created by your service identity (including Claude Tag channel sessions) have an agent: subject and omit act.email, ccr:account_id, account_email and account_uuid. Even for user sessions, the email claims are optional: they are only recorded when the creating request's credentials include an email, and a session dispatched from the CLI may have neither. Key identity on act.sub or ccr:account_id.
The act chain
act nests from the session's creator down to whoever created the environment secret. The creator is outermost, so act.sub gives you them directly.
| Path | Holds |
|---|---|
act.sub | user:<id>, or agent:<id> for your organisation's service identity |
act.email | Creator's email when recorded. Don't require it |
act.attested_by | Reserved for an upstream identity provider attestation. Expect it to be missing |
act.act.sub | The runner that spawned the session: ccr:runner:<runner_id> |
act.act.act.sub | The environment: ccr:pool:<pool_id> |
act.act.act.act | The identity that created the environment secret the runner registered with. The chain stops here |
Scope what you grant
Treat the token as "a coding session started by Priya", not as "Priya". Remember that anything in the session can read it, and that verification is offline: a token stays valid until exp whatever has happened to the session, and there is no revocation feed. When you exchange it for internal credentials:
- Narrow capabilities to the reads and writes a coding task needs. Leave out admin rights the person holds elsewhere.
- Cap lifetime at the token's
expor shorter. - Log as the session: record
ccr:session_idandjtialongside the creator, so every action traces to one session.
Unverified identity variables
The creator also appears in plain environment variables in two places, neither of which verifies anything:
- The orchestrator's
spawn-runnerhook receives values such asCLAUDE_RUNNER_ACCOUNT_EMAILandCLAUDE_RUNNER_ACCOUNT_IDbefore any runner exists. The orchestrator reads them from the work order (the signed, single-use token authorising one runner spawn) without checking its signature. They are trusted because the work order arrives over the orchestrator's connection to Anthropic, authenticated by the environment secret. - Wrapper scripts receive
CCR_SESSION_ACCOUNT_EMAIL, pulled from the token without signature verification. Fine for labels such as commit trailers; not for access decisions.
Use the plain variables for orchestrator choices like which machine image to start. Use CLAUDE_CODE_SESSION_ACCESS_TOKEN whenever a downstream service needs its own cryptographic proof.