Claude Access — a Service's Own Anthropic Identity
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…
Ruleplatform
About
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.
Skill Content
This is the raw markdown that gets installed as a Claude Code rule.
# Claude Access — a Service's Own Anthropic Identity
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.
## The Short Version
1. A platform admin switches **Claude access** on for the service's OAuth client
(**Console → OAuth → your client**).
2. `tawa deploy --prod`. The pod now has four `ANTHROPIC_*` ids.
3. At runtime the service asks Bio-ID for an assertion, exchanges it at Anthropic, and calls
Claude with the token it gets back.
Step 3 is done for you by `@insureco/bio/claude` (`@insureco/bio` 0.15.0+) —
see [Using it from a service](#using-it-from-a-service).
## What the switch does
`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.
## Who can switch it
| 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.
### Switching on can fail, and when it does the switch goes back to off
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.
### Switching off
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.
## What a deploy injects
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 |
- They are **identifiers, not credentials**, and are injected as plain env vars. What
authenticates the service is its own Bio-ID assertion.
- They are reserved: `tawa config set` refuses all four (`RESERVED_ENV_VAR`).
- The names are the ones the Anthropic SDKs read for workload identity federation.
- **A deploy is what puts them in the pod.** Switching Claude access on does not change a
running pod, and switching it off does not remove them until the next deploy.
- If anything goes wrong the deploy continues and the build log gets a `[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](auth.md).
## Using it from a service
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.
```bash
npm install @insureco/bio@^0.15.0
```
```typescript
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 |
- **Federation wins** when both are configured, so a leftover key cannot shadow it.
- `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.
- **Moving an existing service off a key?** Pass `{ 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.
- The helper only touches requests to `https://api.anthropic.com`. A custom
`ANTHROPIC_BASE_URL` bypasses it.
- The service needs `spec.auth` (`sso` or `service-only`) so it has a Bio-ID client at all.
- No `.npmrc` yet? Add `@insureco:registry=https://git.insureco.io/api/packages/insureco/npm/`.
### What the helper does (and how to do it without it)
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).
### Every exchange needs a fresh assertion
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:
- **Cache the Anthropic access token**, until shortly before `expires_in` runs out.
- **Never cache or reuse the assertion.** Ask Bio-ID for a new one each time you exchange.
- A retry of a failed exchange needs a new assertion too.
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.
### Why not the Anthropic SDK's own federation support?
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.)
- Setting `ANTHROPIC_IDENTITY_TOKEN` to an assertion you fetched at startup works exactly once.
## What the builder creates, precisely
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:
```jsonc
{ "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.
## Platform operators
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](#the-builders-own-claude-calls)). 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.
- It must be the organization's **default** workspace. A new service account is a member of
the default workspace only, and the builder does not add workspace memberships.
- The builder's own client (`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.
- Every switch writes a Septor event, `oauth.claude_access_changed`, with the client id, the
outcome and the Anthropic ids. No tokens.
## The builder's own Claude calls
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` |
- Federation wins when both are configured. A malformed id counts as missing.
- `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`.
- The startup line names any missing setting (`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.
- Nothing changes for a developer: `tawa` still uses its own key when it has one and the proxy
when it does not.
## What NOT to do
```bash
# 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
```
```typescript
// 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
}
```
- Do not call Bio-ID or the exchange from a browser. The client secret and the assertion are
server-side only.
- Do not expect the switch to change a running pod. Deploy.
- Do not log the assertion or the access token.
## Common mistakes
- **Expecting `ANTHROPIC_*` in the pod right after the switch.** They arrive on the next deploy.
- **No `spec.auth`.** No OAuth client, so nothing to switch and nothing to authenticate with.
- **Reusing an assertion.** The second exchange fails with an opaque `401`. The reason is only
visible in the Anthropic Console's authentication history (`jti_reused`).
- **Leaving `ANTHROPIC_API_KEY` set.** The SDK keeps using it and federation never runs.
- **Calling something outside `workspace:inference`.** Files, Skills and Managed Agents answer
`403`.
- **Switching it on in prod and testing in sandbox.** The flag is per client, and each
environment has its own.
## See also
- [Authentication](auth.md) — `spec.auth` and the OAuth client this builds on.
- [Config Vars & Managed Secrets](config.md) — reserved platform variables.
- [Build & Deploy Pipeline](build-pipeline.md) — step 14b, where the ids are injected.
Install
Copy the skill content and save it to:
~/.claude/rules/tawa-claude-access.mdComing soon via CLI:
tawa chaac install tawa-claude-accessDetails
- Format
- Rule
- Category
- platform
- Version
- 1.0.23005
- Tokens
- ~4,468
- Updated
- 2026-10-13
platform