Skip to main content

Call Claude as an agent, with no API key

An agent enrolled with cilock enroll agent already has an identity on the TestifySec Platform: a SPIFFE ID of the form spiffe://platform.testifysec.com/tenant/<tenant-id>/agent/<agent-id>. This guide lets that identity call the Claude API directly. The platform mints a short-lived token naming the agent, and Anthropic exchanges it for a Claude access token through workload identity federation (WIF). There's no Anthropic API key to store, rotate or leak.

Video: an agent identity mints a token, exchanges it, calls Claude, and is refused on replay and on a foreign audience

One minute, no narration. Every ID is redacted on screen. Play the video.

How it fits together​

StepWhoWhat
1AgentSends its stored refresh credential to POST /api/agent/federation-token with audience: https://api.anthropic.com.
2PlatformReturns an ES256 JWT, valid for 300 seconds. iss is https://platform.testifysec.com/fulcio/oidc, sub is the agent's SPIFFE ID, and it carries a jti.
3AgentPresents the JWT to Anthropic's POST /v1/oauth/token (RFC 7523 jwt-bearer grant).
4AnthropicChecks the signature against the platform's published keys, matches your federation rule, and returns an access token for the service account.

The platform mints a federation token only for audiences on its allowlist, and https://api.anthropic.com is one of them. A request for any other audience is refused before the credential is checked.

What you need​

  • An agent enrolled on the platform with cilock enroll agent (see the CLI reference). The command stores the agent's refresh credential 0600 in CI/lock's agent store.
  • A Claude Console organization where you can create federation issuers, rules and service accounts.
  • Your platform tenant ID. It's the <tenant-id> segment of the agent's SPIFFE ID.

Federation authenticates a Console service account and bills the workspace the rule names. It does not sign in to a Claude Pro or Max subscription, so subscription usage can't be federated.

Step 1: Register the issuer​

In the Claude Console, open Settings → Workload identity and choose Connect workload. Register an issuer with:

  • Issuer URL: https://platform.testifysec.com/fulcio/oidc. The platform serves its discovery document and JWKS publicly, so leave the discovery endpoint blank.
  • Enforce single-use tokens (JTI replay protection): on. Every platform token carries a jti.
  • Maximum token lifetime: 1 hour is fine. Platform tokens live 300 seconds.

Registering the platform as a WIF issuer in the Claude Console

Step 2: Write the federation rule​

The rule decides which tokens from that issuer are accepted. It needs both of these:

  • Subject prefix: spiffe://platform.testifysec.com/tenant/<tenant-id>/agent/* for every agent in your tenant, or one exact SPIFFE ID for a single agent.
  • Audience: https://api.anthropic.com.

A rule on the subject alone would accept other tenants' agents if the prefix were too loose, and a rule without the audience would accept platform tokens minted for other relying parties. Pick the workspace the tokens should bill to and an OAuth scope: workspace:inference if the agent only calls Claude, workspace:developer if it also needs Files, Skills or Managed Agents.

The federation rule: audience, optional CEL condition, workspace and scope

Step 3: Pick the service account​

The service account is the Anthropic identity the agent acts as. Create one for agents (names are lowercase letters, digits and hyphens) and make sure it's a member of the rule's workspace.

Creating the target service account

The last page of the wizard shows a test snippet with your four IDs: the federation rule (fdrl_…), organization, service account (svac_…) and workspace (wrkspc_…). Keep them. They aren't secrets.

The test page with the IDs the exchange needs, redacted here

Step 4: Mint a token on the agent's machine​

CI/lock keeps the agent credential in agent-credentials.json under its state directory: $CILOCK_STATE_DIR when set, otherwise ~/Library/Application Support/cilock on macOS and ~/.config/cilock on Linux. This pipes the credential straight from the store into the request body, so it never appears on a command line. It mints into a private temporary file and replaces the live token file only when a well-formed token came back, so a reader never sees an empty or half-written file and a failed mint leaves the previous token in place:

store="$HOME/Library/Application Support/cilock/agent-credentials.json" # Linux: ~/.config/cilock/...
token_file="$HOME/.cache/anthropic-wif/token"
mkdir -p "$(dirname "$token_file")" && chmod 700 "$(dirname "$token_file")"
umask 077
tmp=$(mktemp "$token_file.XXXXXX")
if jq '.agents["https://platform.testifysec.com"]
| {tenant_id, agent_id, refresh_credential, audience: "https://api.anthropic.com"}' "$store" \
| curl -sS --fail-with-body -X POST https://platform.testifysec.com/api/agent/federation-token \
-H 'content-type: application/json' --data @- \
| jq -er '.token | select(test("^[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+$"))' > "$tmp"; then
mv -f "$tmp" "$token_file" # rename in the same directory: readers see the old file or the new one
else
rm -f "$tmp"; echo "mint failed; kept the previous token" >&2
fi

The last jq -e decides the if: it exits non-zero on an empty answer, an error body, an empty token or anything that isn't JSON, so the live file is only replaced by a real token, with or without pipefail. The token is good for 300 seconds and for one exchange. Mint a new one for every exchange.

Step 5: Call Claude​

With the SDKs or the API​

Anthropic's SDKs read these variables and do the exchange themselves (WIF reference):

export ANTHROPIC_FEDERATION_RULE_ID=fdrl_<rule-id>
export ANTHROPIC_ORGANIZATION_ID=<organization-uuid>
export ANTHROPIC_SERVICE_ACCOUNT_ID=svac_<service-account-id>
export ANTHROPIC_WORKSPACE_ID=wrkspc_<workspace-id>
export ANTHROPIC_IDENTITY_TOKEN_FILE="$HOME/.cache/anthropic-wif/token"

Anthropic's reference says the SDK "re-reads this file on every exchange", so keep the file fresh: re-run Step 4 on a timer shorter than five minutes for a long-running process.

The raw exchange, if you'd rather see it:

jq -n --rawfile jwt "$token_file" '{
grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
assertion: ($jwt | rtrimstr("\n")),
federation_rule_id: env.ANTHROPIC_FEDERATION_RULE_ID,
organization_id: env.ANTHROPIC_ORGANIZATION_ID,
service_account_id: env.ANTHROPIC_SERVICE_ACCOUNT_ID,
workspace_id: env.ANTHROPIC_WORKSPACE_ID }' \
| curl -sS -X POST https://api.anthropic.com/v1/oauth/token \
-H 'content-type: application/json' --data @- \
| jq '{scope, expires_in}'

A success returns the granted scope and an access token of about ten minutes, which you send as Authorization: Bearer <token>.

With Claude Code​

Claude Code reads the same variables. With the five variables above set, run it as usual:

unset ANTHROPIC_API_KEY ANTHROPIC_AUTH_TOKEN CLAUDE_CODE_OAUTH_TOKEN ANTHROPIC_PROFILE
claude -p "Reply with exactly: federated ok"

We tested this with Claude Code 2.1.308 using a fresh CLAUDE_CONFIG_DIR with no login in it, so federation was the only credential. Claude Code ranks federation variables above a /login but below ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN, apiKeyHelper and CLAUDE_CODE_OAUTH_TOKEN, which is why the unset matters. /status shows a Profile row when federation is the credential in use. We checked a single claude -p run; for an interactive session longer than the access token, keep refreshing the token file as above.

Troubleshooting​

SymptomCauseFix
403 audience_not_allowed from the platformThe audience you asked for isn't on the platform's allowlist.Use exactly https://api.anthropic.com.
401 from the platformThe agent credential is missing, expired or revoked. Agent identities are time-bound.Enroll again with cilock enroll agent.
401 Authentication failed from Anthropic on a second tryThe same token was exchanged twice. Each jti is accepted once.Mint a fresh token for every exchange.
401 Authentication failed from Anthropic on the first tryThe rule didn't match: wrong subject prefix, missing audience, or the token expired.Anthropic's response is deliberately opaque. Open the authentication history in the Console, which names the failed check.
An error that names something other than AnthropicANTHROPIC_BASE_URL points at a proxy, and the exchange went there.Unset it, or make sure the proxy forwards /v1/oauth/token.
Claude Code still uses your own login or keyA higher-precedence credential is set.Unset the variables in the claude example above. Check /status.