Skip to content

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:

  1. Always check aud against your own environment ID (the ccpool_... value on the Cloud environments admin page). This rejects tokens from every other organisation's environments.
  2. 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:

StepCheckReject when
1PrefixThe value doesn't start with sk-ant-cc- (then strip it)
2SignatureNo 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
3Issueriss is not exactly ccr
4AudienceThe aud array doesn't contain your ccpool_... environment ID
5Roleccr:role is not exactly session_worker. Environment secrets, runner tokens and work orders are signed by the same keys but carry other roles
6Expiryexp is in the past
7IdentityRead 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 whichever claude is on PATH.
  • Use jq -re, not jq -r. With -r alone a missing claim prints the string null and exits 0, quietly passing a bad value along.
  • --no-verify skips 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.

ClaimTypeMeaning
issstringAlways ccr
substringccr:session:<session_id>
audstring arrayAlways includes anthropic-api; for self-hosted sessions also your ccpool_... ID. Check the environment ID, not anthropic-api
expnumberExpiry, Unix seconds. Four hours by default, eight maximum
iatnumberIssued at, Unix seconds
jtistringUnique token ID
ccr:rolestringsession_worker for session tokens
ccr:session_idstringSession ID, matching the end of sub
ccr:pool_idstringYour environment ID, matching the value in aud
ccr:org_idstringYour Anthropic organisation ID
ccr:account_idstringThe 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_emailstringLegacy duplicate of act.email; absent whenever that is
organization_uuidstringLegacy: organisation UUID
account_uuidstringLegacy: creating user's account UUID
actobjectRFC 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.

PathHolds
act.subuser:<id>, or agent:<id> for your organisation's service identity
act.emailCreator's email when recorded. Don't require it
act.attested_byReserved for an upstream identity provider attestation. Expect it to be missing
act.act.subThe runner that spawned the session: ccr:runner:<runner_id>
act.act.act.subThe environment: ccr:pool:<pool_id>
act.act.act.actThe 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 exp or shorter.
  • Log as the session: record ccr:session_id and jti alongside 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-runner hook receives values such as CLAUDE_RUNNER_ACCOUNT_EMAIL and CLAUDE_RUNNER_ACCOUNT_ID before 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.