Authenticate model calls
For: tenant administrators and cost owners.
The edge authenticates provider calls. Nodes use a local proxy and do not receive provider keys.
How calls are signed
Most providers take a key in a header, Bedrock's and Azure's API keys included: that is auth.kind: header, the default. Provider records also support the standard authentication methods below. The edge authenticates each call; the key never leaves the edge, and neither does a token it gets for one.
auth.kind | What the edge does | key_secret holds | Also set |
|---|---|---|---|
header | Sends the key in auth.header after auth.prefix | The key | auth.header, auth.prefix |
oauth2_client_credentials | Trades the client's ID and secret for a bearer token at auth.token_url (RFC 6749, 4.4), as Microsoft Entra does for Azure OpenAI and AI Foundry | The client secret | auth.token_url, auth.client_id, auth.scope; auth.client_auth post (the default) or basic |
oauth2_jwt_bearer | Signs an RS256 assertion with a service account's key and trades it for a bearer token (RFC 7523), as Google does for Vertex AI | The service account's key file (JSON), or a PEM key with auth.client_id as its issuer | auth.token_url, auth.scope; auth.audience (the token URL when empty) |
sigv4 | Signs every call with AWS Signature Version 4 over its method, path, headers and body, as Bedrock's runtime takes it | The secret access key | auth.service (bedrock), auth.region, auth.access_key_secret (the access key ID's secret), auth.session_token_secret for temporary credentials |
federated | Exchanges the calling agent's own token from the tenant's issuer at the cloud for a credential that lives an hour (cloud federation): an AWS session signs SigV4, a Google or Entra token goes in as a bearer token | Nothing: a federated record stores no key, and one with key_secret is refused | auth.cloud (aws, gcp or entra); AWS: auth.role, auth.region, auth.service; Google: auth.workload_provider, auth.service_account, auth.scope; Entra: auth.directory, auth.client_id, auth.scope; auth.audience when the cloud's trust names another |
A token goes in Authorization: Bearer unless auth.header says otherwise. Each edge keeps a tenant's token for its provider for as long as the token endpoint says it lives, mints a new one five minutes before it ends, and never logs it, writes it down or sends it to a node.
A token endpoint's answer must be a bearer token of plain characters, or the call fails with 502 oz0_token and the endpoint's error code, never its description. Token requests follow no redirect, and a token URL is https, or http on the edge's own machine only. A tenant's own record dials its token URL under the same rule as its base URL: public addresses only, unless a cluster admin marks it --internal.
Presets set the rest:
| Preset | Kind | Set yourself |
|---|---|---|
vertex | oauth2_jwt_bearer at https://oauth2.googleapis.com/token, scope cloud-platform; Claude in anthropic-messages through rawPredict and streamRawPredict, Gemini through generateContent; the model is in the path | --base-url https://<region>-aiplatform.googleapis.com, --region, the key file in GOOGLE_SERVICE_ACCOUNT_KEY |
azure-openai-entra | oauth2_client_credentials, scope https://ai.azure.com/.default; openai-chat and openai-responses | --base-url https://<resource>.openai.azure.com/openai, --token-url https://login.microsoftonline.com/<directory>/oauth2/v2.0/token, --client-id, --region, AZURE_CLIENT_SECRET |
azure-foundry-entra | The same, for Claude in AI Foundry, in anthropic-messages | --base-url https://<resource>.services.ai.azure.com/anthropic, the token URL, client and region |
bedrock | sigv4 for bedrock; Claude through InvokeModel and its stream, and the OpenAI-compatible chat completions; the model is in the path | --base-url https://bedrock-runtime.<region>.amazonaws.com, --aws-region, --region, AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY |
bedrock-key | A Bedrock API key in Authorization: Bearer | The base URL and AWS_BEARER_TOKEN_BEDROCK |
bedrock-federated | federated with AWS, SigV4 for bedrock; no stored key | The base URL, --aws-role, --aws-region, --region |
vertex-federated | federated with Google Cloud, scope cloud-platform; no stored key | The base URL, --gcp-workload-provider, --gcp-service-account if any, --region |
azure-openai-federated | federated with Entra, scope https://ai.azure.com/.default; openai-chat and openai-responses; no stored key | The base URL, which the servers must name with --cloud-endpoint entra-resources= (Cloud federation), --entra-directory, --client-id, --region |
azure-foundry-federated | The same, for Claude in AI Foundry, in anthropic-messages | The base URL, directory, client and region |
oz0 secret set GOOGLE_SERVICE_ACCOUNT_KEY --from-file vertex-sa.json \
--host europe-west1-aiplatform.googleapis.com # it mints tokens at oauth2.googleapis.com, for this address only
oz0 provider set vertex --preset vertex --base-url https://europe-west1-aiplatform.googleapis.com --region eu
printf %s "$AWS_ACCESS_KEY_ID" | oz0 secret set AWS_ACCESS_KEY_ID --host bedrock-runtime.eu-north-1.amazonaws.com
printf %s "$AWS_SECRET_ACCESS_KEY" | oz0 secret set AWS_SECRET_ACCESS_KEY --host bedrock-runtime.eu-north-1.amazonaws.com
oz0 provider set bedrock --preset bedrock --base-url https://bedrock-runtime.eu-north-1.amazonaws.com \
--aws-region eu-north-1 --region eu
oz0 provider show bedrock
A federated record's credential names the agent whose call it is, so it is only for agents' model calls: the edge
takes the agent from the call's binding, and a call that names none (an operator's, say) is refused with
403 oz0_federation. The edge keeps a job's credential for its next calls until a few minutes before it ends; each
exchange is audited as token.exchanged. The key-based presets stay for clusters whose issuer no cloud can reach.
The gateway preserves each provider's API format and meters calls in that format while enforcing the tenant's controls. Through Vertex and Bedrock that is Anthropic's Messages (and OpenAI's chat completions on Bedrock), metered as they are elsewhere; Bedrock's streamed answers come in AWS's binary event-stream framing, which the gateway passes on byte for byte and reads only to count tokens.
A call in a format it cannot meter, such as Bedrock's Converse API, is refused with 400 oz0_unmetered: no setting lets calls past the meter.
A service account or an AWS access key reaches far more than models, so a record that signs with one (oauth2_jwt_bearer, sigv4, or federated with AWS or Google), or whose base URL is Vertex AI's or Bedrock's, passes only the model calls the gateway meters, and refuses everything else with oz0_unmetered before it gets a token or signs anything:
| Through | Passed | Refused, for example |
|---|---|---|
| Vertex AI | .../publishers/anthropic/models/<model>:rawPredict and :streamRawPredict; .../publishers/google/models/<model>:generateContent, :streamGenerateContent and :countTokens; .../endpoints/openapi/chat/completions | other publishers' models (publishers/meta/...:rawPredict), customJobs, batchPredictionJobs, deployed endpoints' :predict |
| Bedrock | model/<id>/invoke and invoke-with-response-stream for Anthropic's models (anthropic.*, a cross-region profile such as eu.anthropic.*, or a foundation model's or system inference profile's ARN); openai/v1/chat/completions | other vendors' models (meta.*, amazon.*, cohere.*), Converse, async-invoke, guardrails, an application inference profile's or a provisioned model's ARN, which does not say its model |
Any provider's key reaches more than its models, so on every record a call other than a GET that is no model call in a format the gateway meters is refused with oz0_unmetered too: a batch (v1/messages/batches, v1/batches), a file, a fine-tuning job, OpenAI's images, audio and moderations, or Gemini's embedContent and batchEmbedContents would run at $0, and a realtime session's key would leave the edge.
Messages, chat completions, embeddings, the Responses API and Gemini's generateContent are metered and pass. So is a Responses API call with "background": true, whose answer comes later and elsewhere. A GET, such as listing a provider's models, passes. A token count (messages/count_tokens, :countTokens) is free at the provider and reserves nothing on budgets.
In a Vertex AI path, the project, the location and the model may hold only letters, digits, ., _, @ and -. A path with any escape in it, such as an encoded ?, # or /, is refused. The gateway checks the path exactly as it sends it upstream.
Agents in Pydantic AI call a record's first format at <base_url>/v1/..., so they reach Azure and AI Foundry; Vertex's and Bedrock's own paths are for harness agents and SDKs that speak them, such as Claude Code pointed at the node's proxy with its Vertex or Bedrock sign-in skipped.
Two tenants, one key
Provider cache isolation follows the provider's account, project or workspace rules, which may span several API keys. For example, Claude caches are isolated by workspace on the Claude API and by organization on Bedrock and Google-managed Claude; cache hits require an identical prompt prefix. See Claude's cache storage and sharing rules.
Orchestrator Zero detects identical credentials reused between tenants. This check does not establish whether different keys share a provider cache. Each time an edge opens a tenant's key, it records a keyed hash of it (an HMAC under a key derived from the cluster's secrets key, which only the edges hold; never the key itself). A key that two tenants used in the last 30 days is a finding:
- for cluster admins, in
oz0 doctor(shared provider key, naming the tenants and their providers) and on the Models page; - for each tenant's admins, in
oz0 doctor --tenant <t>and on the Models and Credentials pages, without naming the other tenant: another tenant of this cluster uses the same key; prompt caches may be shared.
The tenant's other members see the providers without the note.
Give each tenant credentials for its own provider isolation boundary, such as a separate project or workspace. A different key in a shared workspace may not separate caches. A pool's members are hashed one by one, as <provider>/<member>, so the finding names the member whose key is shared.
Why gateway provider keys stay on the edge
Each node runs a local LLM proxy. The agent runtime and plugins that use it see ANTHROPIC_BASE_URL and OPENAI_BASE_URL pointing at it, with a per-start token as their API key. Unmodified SDKs work: the proxy forwards calls to the edge with the node's certificate, and the edge keeps the gateway provider's key. When an edge cannot be reached, the proxy tries the next one; a call that an edge has already taken is never sent to a second edge, so a provider never runs it twice, and the activity's own retry decides what happens next.
Because the gateway preserves the provider's API format, provider features such as prompt caching and extended thinking work without translation. Its anthropic-* request headers, betas included, reach the provider, and the provider's rate-limit and retry headers come back: retry-after, retry-after-ms, x-should-retry, anthropic-ratelimit-* and x-ratelimit-*. The gateway follows no redirect: a provider's 307 or 308 comes back as its answer, so the tenant's key never goes on to the host it names.
This protection applies to gateway provider keys. Remote harnesses receive their provider credentials on the node and send requests to the provider themselves.
Why nodes never see keys: Gateway provider keys.