# Call Claude as an agent, with no API key

Source: https://www.testifysec.com/docs/cilock/guides/call-claude-as-an-agent/

Let an enrolled TestifySec agent identity call the Claude API and run Claude Code through Anthropic workload identity federation, with five-minute single-use tokens and no stored Anthropic 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](https://platform.claude.com/docs/en/manage-claude/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](https://www.testifysec.com/media/docs/demo-thumbnail-d7d11dbea162a6d893d7dc6b4ad7e8d2.jpg)](https://cilock.dev/img/guides/anthropic-wif/demo.mp4)

 

*One minute, no narration. Every ID is redacted on screen. [Play the video](https://cilock.dev/img/guides/anthropic-wif/demo.mp4).*

 

## How it fits together

 

| Step | Who | What |
| --- | --- | --- |
| 1 | Agent | Sends its stored refresh credential to `POST /api/agent/federation-token` with `audience: https://api.anthropic.com`. |
| 2 | Platform | Returns 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`. |
| 3 | Agent | Presents the JWT to Anthropic's `POST /v1/oauth/token` (RFC 7523 `jwt-bearer` grant). |
| 4 | Anthropic | Checks 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](https://www.testifysec.com/docs/cilock/reference/cli)). 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](https://www.testifysec.com/media/docs/01-issuer-78206e006c092414c1795a7836c31555.webp)

 

## 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](https://www.testifysec.com/media/docs/02-rule-827950a7f552eb8569ebc8b03ee1a9b7.webp)

 

## 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](https://www.testifysec.com/media/docs/03-service-account-975bf07e63a94e964a7431316d0cb937.webp)

 

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](https://www.testifysec.com/media/docs/04-test-listening-2c0024b756d05f749d8fc7d557ecaa33.webp)

 

## 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:

 

```bash
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](https://platform.claude.com/docs/en/manage-claude/wif-reference#environment-variables)):

 

```bash
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:

 

```bash
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:

 

```bash
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](https://code.claude.com/docs/en/authentication#authentication-precedence) 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

 

| Symptom | Cause | Fix |
| --- | --- | --- |
| `403 audience_not_allowed` from the platform | The `audience` you asked for isn't on the platform's allowlist. | Use exactly `https://api.anthropic.com`. |
| `401` from the platform | The 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 try | The 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 try | The rule didn't match: wrong subject prefix, missing audience, or the token expired. | Anthropic's response is deliberately opaque. Open the [authentication history](https://platform.claude.com/settings/workload-identity-federation?tab=history) in the Console, which names the failed check. |
| An error that names something other than Anthropic | `ANTHROPIC_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 key | A higher-precedence credential is set. | Unset the variables in the `claude` example above. Check `/status`. |

Reference generated from the product documentation. Match commands and support details to your installed release.
