A service that calls Claude does not need an Anthropic API key. Switch Claude access on for its OAuth client and the builder gives that service its own identity at Anthropic. The service then proves who it is with a short-lived token from Bio-ID, and Anthropic hands back a short-lived access token for the Messages API. Nothing long-lived is stored anywhere.
tawa deploy --prod. The pod now has four ANTHROPIC_* ids.Step 3 is done for you by @insureco/bio/claude (@insureco/bio 0.15.0+) —
see Using it from a service.
claudeAccessEnabled is a per-OAuth-client flag in Bio-ID. A client with it on may ask Bio-ID
for an assertion: a 5-minute JWT whose audience is https://api.anthropic.com and whose
sub is the client id. A client with it off is refused.
Anthropic trusts Bio-ID as an identity provider (Workload Identity Federation). When the flag goes on, the builder creates two things at Anthropic for that client, and only that client:
| Service account | The identity the service acts as. Organization role developer. |
| Federation rule | Accepts an assertion only when sub is this client's id and the token came from the client_credentials grant. Grants workspace:inference in the platform workspace. |
workspace:inference covers the Messages API (including streaming and token counting), the
Models API, and the OpenAI-compatible chat endpoint. It does not cover Files, Skills, Managed
Agents or anything administrative.
Usage is billed to the platform's Anthropic workspace. That is why switching it on is restricted.
| Action | Who |
|---|---|
| Switch on | Platform admin only |
| Switch off | Platform admin, or the org that owns the client |
The flag is per client, and a service has one client per environment, so prod and sandbox are switched separately.
The console never shows Claude access as on for a service that cannot actually use it. If the builder cannot create the Anthropic identity, it sets the flag back to off and returns the reason:
| Response | Means |
|---|---|
502 CLAUDE_FEDERATION_FAILED | Anthropic refused or could not be reached. The message says which call failed. |
503 CLAUDE_FEDERATION_NOT_CONFIGURED | This builder has no Anthropic federation configured. A platform operator matter. |
error.details.flagReverted is true when the flag was set back. If it is false, Bio-ID
refused the revert and the flag is still on: switch it off by hand.
Bio-ID stops issuing assertions for the client at once. That is the control that matters. The
builder then archives the federation rule and the service account at Anthropic. If that cleanup
fails, the flag still stays off and the response is a 200 carrying a warning; switching it
off again retries the cleanup.
An Anthropic access token the service already holds keeps working until it expires, which is a matter of minutes.
On every deploy, after OAuth provisioning, the builder checks the client's flag. When it is on, the builder makes sure the service account and rule still exist (re-creating either if it is missing) and injects:
| Variable | Example | What it is |
|---|---|---|
ANTHROPIC_FEDERATION_RULE_ID | fdrl_… | This service's federation rule |
ANTHROPIC_ORGANIZATION_ID | a UUID | The platform's Anthropic organization |
ANTHROPIC_SERVICE_ACCOUNT_ID | svac_… | This service's service account |
ANTHROPIC_WORKSPACE_ID | wrkspc_… | The workspace usage is billed to |
tawa config set refuses all four (RESERVED_ENV_VAR).[warn] line; the
variables are simply not injected that time.The service must have an OAuth client, which means spec.auth in catalog-info.yaml
(mode: sso or mode: service-only). Without one there is no BIO_CLIENT_ID to ask Bio-ID
with. See Authentication.
Use the helper. It makes both calls, caches the Claude token until just before it expires and refreshes it itself, so a client you build once keeps working. Server-side only.
npm install @insureco/bio@^0.15.0
import Anthropic from '@anthropic-ai/sdk'
import { claudeAuthFromEnv } from '@insureco/bio/claude'
const auth = claudeAuthFromEnv(process.env, { logger })
// auth.mode: 'federation' | 'api-key' | 'unconfigured'
// The SDK insists on an apiKey; this placeholder never leaves the process.
const client = new Anthropic({ apiKey: 'federated', fetch: auth.fetch })
Not using the SDK? auth.fetch is a drop-in fetch, and await auth.headers() gives the
auth header for axios or anything else. CommonJS works too:
const { claudeAuthFromEnv } = require('@insureco/bio/claude').
auth.mode | When | What auth.fetch sends |
|---|---|---|
federation | the three injected ids and BIO_ID_URL / BIO_CLIENT_ID / BIO_CLIENT_SECRET are all set | Authorization: Bearer <short-lived token>; removes x-api-key and anthropic-workspace-id |
api-key | federation is not configured and ANTHROPIC_API_KEY is set | x-api-key, as before |
unconfigured | neither | nothing: a Claude request throws, naming the missing settings |
auth.problems lists the missing federation settings by name. Log auth.mode once at boot;
after a deploy that line tells you which credential the pod is really using.{ fallbackToApiKey: true } for the rollout.
If an exchange fails and the key is still set, that request uses the key and one warning is
logged. Remove the flag, then the key, once the boot line says federation in production.https://api.anthropic.com. A custom
ANTHROPIC_BASE_URL bypasses it.spec.auth (sso or service-only) so it has a Bio-ID client at all..npmrc yet? Add @insureco:registry=https://git.insureco.io/api/packages/insureco/npm/.Two calls.
1. Ask Bio-ID for an assertion.
POST {BIO_ID_URL}/api/oauth/token
Content-Type: application/json
{ "grant_type": "client_credentials",
"client_id": "<BIO_CLIENT_ID>",
"client_secret": "<BIO_CLIENT_SECRET>",
"audience": "https://api.anthropic.com" }
The access_token in the reply is the assertion. It lives five minutes.
2. Exchange it at Anthropic.
POST https://api.anthropic.com/v1/oauth/token
Content-Type: application/json
anthropic-version: 2023-06-01
{ "grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer",
"assertion": "<the assertion from step 1>",
"federation_rule_id": "<ANTHROPIC_FEDERATION_RULE_ID>",
"organization_id": "<ANTHROPIC_ORGANIZATION_ID>",
"service_account_id": "<ANTHROPIC_SERVICE_ACCOUNT_ID>",
"workspace_id": "<ANTHROPIC_WORKSPACE_ID>" }
The reply is { access_token, token_type, expires_in, scope }. Send access_token to the
Claude API as Authorization: Bearer <token>, or pass it to the SDK as its auth token
(authToken in TypeScript, auth_token in Python).
An assertion carries a jti and Anthropic accepts it once. A second exchange with the same
assertion is rejected with a bare 401 Authentication failed, whatever is left of its five
minutes.
So:
expires_in runs out.Expect expires_in to be about ten minutes, not an hour. The rule allows an hour, but Anthropic
caps an access token at twice the remaining life of the assertion that bought it.
The SDKs can perform the exchange themselves from the injected variables, but they need an
identity token — the assertion — supplied through ANTHROPIC_IDENTITY_TOKEN_FILE or
ANTHROPIC_IDENTITY_TOKEN, and a fresh one for every exchange. Nothing writes an assertion into
the pod, and a static value cannot work: it expires in five minutes and is accepted once. The
helper is what supplies a fresh one, and it works with every SDK version and with raw HTTP.
Two things that bite if you try the SDK's automatic path anyway:
ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN in the environment takes precedence over
federation in the SDK. (Through the helper, federation wins.)ANTHROPIC_IDENTITY_TOKEN to an assertion you fetched at startup works exactly once.For operators and for anyone reading the Anthropic Console.
Both resources carry the same name, derived from the client id:
| Client id | Name at Anthropic |
|---|---|
riskatlas-prod | riskatlas-prod |
client_f7b6eca7fe3cdefca6709838c34f1ece | client-f7b6eca7fe3cdefca6709838c34f1ece-x3c30418180e1 |
A client id that is already lowercase letters, digits and hyphens is used as it is. Anything
else is lower-cased, has every other character replaced with -, and gets -x plus the first
12 hex characters of sha256(client id) appended, so two client ids can never share a name.
The rule:
{ "name": "<name>",
"issuer_id": "<the Bio-ID issuer>",
"match": { "condition": "claims.sub == \"<client id>\" && claims.token_type == \"client_credentials\"" },
"target": { "type": "service_account", "service_account_id": "<the service account>" },
"workspace_id": "<the platform workspace>",
"oauth_scope": "workspace:inference",
"token_lifetime_seconds": 3600 }
Nothing about this is stored in the builder's database. The name is how the builder finds what it created, which is also why a deploy can repair it.
If a rule with that name already exists and is not exactly this — a different condition, target, scope, issuer or workspace — the builder reports it and stops. It never adopts a rule it cannot vouch for, and it never archives one it cannot prove belongs to that client. Fix or archive the rule in the Anthropic Console, then switch Claude access on again.
The builder acts at Anthropic as its own admin identity: it mints a Bio-ID assertion from its
own BIO_CLIENT_ID / BIO_CLIENT_SECRET and exchanges it through one Console-created rule
that grants org:admin. That token is held in memory only, for a few minutes, and never
appears in a log line, a response or an error message. An org:admin token obtained this way
can only manage rules scoped workspace:inference or workspace:developer, so the builder
cannot grant anything broader than what a service gets here.
| Builder env var | Value |
|---|---|
ANTHROPIC_ORG_ID | Anthropic organization UUID |
ANTHROPIC_FEDERATION_ISSUER_ID | fdis_… — Bio-ID registered as a federation issuer |
ANTHROPIC_ADMIN_RULE_ID | fdrl_… — the Console-created rule that grants the builder org:admin |
ANTHROPIC_ADMIN_SERVICE_ACCOUNT_ID | svac_… — that rule's admin service account |
ANTHROPIC_FEDERATION_WORKSPACE_ID | wrkspc_… — the workspace service rules are bound to |
ANTHROPIC_INFERENCE_RULE_ID | fdrl_… — a separate, inference-only rule for the builder's own Claude calls (see below). Never the admin rule |
The first five are optional and belong together. If any is missing or malformed the feature is
off: switching Claude access on is refused with 503, and deploys inject nothing. The sixth is
independent of them: it switches how the builder authenticates its own Claude calls, nothing else.
ANTHROPIC_FEDERATION_WORKSPACE_ID is its own setting. It is not the builder's
ANTHROPIC_WORKSPACE_ID, which belongs to the AI proxy's API key and may name a different
workspace.builder-prod) is never given a service identity, and its flag is
never auto-reverted. That flag is what lets the builder obtain its admin assertion.oauth.claude_access_changed, with the client id, the
outcome and the Anthropic ids. No tokens.The builder calls Claude itself: the /ai/chat proxy the tawa CLI falls back to when a
developer has no key of their own, the AI advice on tawa troubleshoot, and the changelog
summaries written on every deploy. Since iec-builder#274 those calls authenticate the same way a
service's do — @insureco/bio/claude, with the builder's own builder-prod client — rather than
with a static ANTHROPIC_API_KEY.
The difference from the provisioning identity above is the rule. Provisioning goes through the
Console-created org:admin rule (ANTHROPIC_ADMIN_RULE_ID). Inference goes through a
separate, inference-only rule, ANTHROPIC_INFERENCE_RULE_ID, scoped workspace:inference
and targeting the same service account. The admin rule is never used for inference: the builder
refuses to run with ANTHROPIC_INFERENCE_RULE_ID set to the admin rule's id, and reports it.
authMode | When | What the proxy sends to Anthropic |
|---|---|---|
federation | ANTHROPIC_INFERENCE_RULE_ID is set and well formed, together with ANTHROPIC_ORG_ID, ANTHROPIC_ADMIN_SERVICE_ACCOUNT_ID and the builder's BIO_ID_URL / BIO_CLIENT_ID / BIO_CLIENT_SECRET | Authorization: Bearer <short-lived token>; no x-api-key, no anthropic-workspace-id |
api-key | federation is not configured and ANTHROPIC_API_KEY is set | x-api-key, plus anthropic-workspace-id when ANTHROPIC_WORKSPACE_ID is set — exactly as before |
unconfigured | neither | nothing: GET /ai/status answers available: false and POST /ai/chat answers 503 AI_UNAVAILABLE |
fallbackToApiKey is on for the rollout: if an exchange fails while the key is still set, that
request uses the key and the builder log carries one warning per outage. Remove the key once
the builder's startup line says Claude API auth: federation.ANTHROPIC_INFERENCE_RULE_ID is not set, …) and
never a value. GET /ai/status carries authMode beside available and model; the CLI
keeps reading the latter two.tawa still uses its own key when it has one and the proxy
when it does not.# WRONG: paste an Anthropic API key into a service that has Claude access
tawa config set --secret ANTHROPIC_API_KEY=sk-ant-...
# The key shadows federation in the SDK, and it is the long-lived secret this replaces.
# WRONG: set the injected ids by hand
tawa config set ANTHROPIC_FEDERATION_RULE_ID=fdrl_... # refused: RESERVED_ENV_VAR
// WRONG: one assertion for the life of the process
const assertion = await getAssertion() // at startup
setInterval(() => exchange(assertion), 9 * 60_000) // 401 from the second call on
// RIGHT: a new assertion for every exchange; cache only what Anthropic returns
async function claudeToken() {
if (cached && cached.expiresAt - 60_000 > Date.now()) return cached.token
const assertion = await getAssertion() // fresh, every time
const { access_token, expires_in } = await exchange(assertion)
cached = { token: access_token, expiresAt: Date.now() + expires_in * 1000 }
return access_token
}
ANTHROPIC_* in the pod right after the switch. They arrive on the next deploy.spec.auth. No OAuth client, so nothing to switch and nothing to authenticate with.401. The reason is only
visible in the Anthropic Console's authentication history (jti_reused).ANTHROPIC_API_KEY set. The SDK keeps using it and federation never runs.workspace:inference. Files, Skills and Managed Agents answer
403.spec.auth and the OAuth client this builds on.Last updated: October 6, 2026