---
title: "Authenticate model calls"
description: "The edge authenticates provider calls. Nodes use a local proxy and do not receive provider keys."
canonical_url: "https://docs.oz0.ai/spend/models-and-keys/authentication"
---
# Authenticate model calls

> The edge authenticates provider calls. Nodes use a local proxy and do not receive provider keys.

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.

<table>
<thead>
  <tr>
    <th>
      <code>
        auth.kind
      </code>
    </th>
    
    <th>
      What the edge does
    </th>
    
    <th>
      <code>
        key_secret
      </code>
      
       holds
    </th>
    
    <th>
      Also set
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        header
      </code>
    </td>
    
    <td>
      Sends the key in <code>
        auth.header
      </code>
      
       after <code>
        auth.prefix
      </code>
    </td>
    
    <td>
      The key
    </td>
    
    <td>
      <code>
        auth.header
      </code>
      
      , <code>
        auth.prefix
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        oauth2_client_credentials
      </code>
    </td>
    
    <td>
      Trades the client's ID and secret for a bearer token at <code>
        auth.token_url
      </code>
      
       (RFC 6749, 4.4), as Microsoft Entra does for Azure OpenAI and AI Foundry
    </td>
    
    <td>
      The client secret
    </td>
    
    <td>
      <code>
        auth.token_url
      </code>
      
      , <code>
        auth.client_id
      </code>
      
      , <code>
        auth.scope
      </code>
      
      ; <code>
        auth.client_auth
      </code>
      
       <code>
        post
      </code>
      
       (the default) or <code>
        basic
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        oauth2_jwt_bearer
      </code>
    </td>
    
    <td>
      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
    </td>
    
    <td>
      The service account's key file (JSON), or a PEM key with <code>
        auth.client_id
      </code>
      
       as its issuer
    </td>
    
    <td>
      <code>
        auth.token_url
      </code>
      
      , <code>
        auth.scope
      </code>
      
      ; <code>
        auth.audience
      </code>
      
       (the token URL when empty)
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        sigv4
      </code>
    </td>
    
    <td>
      Signs every call with AWS Signature Version 4 over its method, path, headers and body, as Bedrock's runtime takes it
    </td>
    
    <td>
      The secret access key
    </td>
    
    <td>
      <code>
        auth.service
      </code>
      
       (<code>
        bedrock
      </code>
      
      ), <code>
        auth.region
      </code>
      
      , <code>
        auth.access_key_secret
      </code>
      
       (the access key ID's secret), <code>
        auth.session_token_secret
      </code>
      
       for temporary credentials
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        federated
      </code>
    </td>
    
    <td>
      Exchanges the calling agent's own token from the tenant's issuer at the cloud for a credential that lives an hour (<a href="https://docs.oz0.ai/fleet/cloud-federation">
        cloud federation
      </a>
      
      ): an AWS session signs SigV4, a Google or Entra token goes in as a bearer token
    </td>
    
    <td>
      Nothing: a federated record stores no key, and one with <code>
        key_secret
      </code>
      
       is refused
    </td>
    
    <td>
      <code>
        auth.cloud
      </code>
      
       (<code>
        aws
      </code>
      
      , <code>
        gcp
      </code>
      
       or <code>
        entra
      </code>
      
      ); AWS: <code>
        auth.role
      </code>
      
      , <code>
        auth.region
      </code>
      
      , <code>
        auth.service
      </code>
      
      ; Google: <code>
        auth.workload_provider
      </code>
      
      , <code>
        auth.service_account
      </code>
      
      , <code>
        auth.scope
      </code>
      
      ; Entra: <code>
        auth.directory
      </code>
      
      , <code>
        auth.client_id
      </code>
      
      , <code>
        auth.scope
      </code>
      
      ; <code>
        auth.audience
      </code>
      
       when the cloud's trust names another
    </td>
  </tr>
</tbody>
</table>

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:

<table>
<thead>
  <tr>
    <th>
      Preset
    </th>
    
    <th>
      Kind
    </th>
    
    <th>
      Set yourself
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        vertex
      </code>
    </td>
    
    <td>
      <code>
        oauth2_jwt_bearer
      </code>
      
       at <code>
        https://oauth2.googleapis.com/token
      </code>
      
      , scope <code>
        cloud-platform
      </code>
      
      ; Claude in <code>
        anthropic-messages
      </code>
      
       through <code>
        rawPredict
      </code>
      
       and <code>
        streamRawPredict
      </code>
      
      , Gemini through <code>
        generateContent
      </code>
      
      ; the model is in the path
    </td>
    
    <td>
      <code>
        --base-url https://&lt;region>-aiplatform.googleapis.com
      </code>
      
      , <code>
        --region
      </code>
      
      , the key file in <code>
        GOOGLE_SERVICE_ACCOUNT_KEY
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        azure-openai-entra
      </code>
    </td>
    
    <td>
      <code>
        oauth2_client_credentials
      </code>
      
      , scope <code>
        https://ai.azure.com/.default
      </code>
      
      ; <code>
        openai-chat
      </code>
      
       and <code>
        openai-responses
      </code>
    </td>
    
    <td>
      <code>
        --base-url https://&lt;resource>.openai.azure.com/openai
      </code>
      
      , <code>
        --token-url https://login.microsoftonline.com/&lt;directory>/oauth2/v2.0/token
      </code>
      
      , <code>
        --client-id
      </code>
      
      , <code>
        --region
      </code>
      
      , <code>
        AZURE_CLIENT_SECRET
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        azure-foundry-entra
      </code>
    </td>
    
    <td>
      The same, for Claude in AI Foundry, in <code>
        anthropic-messages
      </code>
    </td>
    
    <td>
      <code>
        --base-url https://&lt;resource>.services.ai.azure.com/anthropic
      </code>
      
      , the token URL, client and region
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        bedrock
      </code>
    </td>
    
    <td>
      <code>
        sigv4
      </code>
      
       for <code>
        bedrock
      </code>
      
      ; Claude through <code>
        InvokeModel
      </code>
      
       and its stream, and the OpenAI-compatible chat completions; the model is in the path
    </td>
    
    <td>
      <code>
        --base-url https://bedrock-runtime.&lt;region>.amazonaws.com
      </code>
      
      , <code>
        --aws-region
      </code>
      
      , <code>
        --region
      </code>
      
      , <code>
        AWS_ACCESS_KEY_ID
      </code>
      
       and <code>
        AWS_SECRET_ACCESS_KEY
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        bedrock-key
      </code>
    </td>
    
    <td>
      A Bedrock API key in <code>
        Authorization: Bearer
      </code>
    </td>
    
    <td>
      The base URL and <code>
        AWS_BEARER_TOKEN_BEDROCK
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        bedrock-federated
      </code>
    </td>
    
    <td>
      <code>
        federated
      </code>
      
       with AWS, SigV4 for <code>
        bedrock
      </code>
      
      ; no stored key
    </td>
    
    <td>
      The base URL, <code>
        --aws-role
      </code>
      
      , <code>
        --aws-region
      </code>
      
      , <code>
        --region
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        vertex-federated
      </code>
    </td>
    
    <td>
      <code>
        federated
      </code>
      
       with Google Cloud, scope <code>
        cloud-platform
      </code>
      
      ; no stored key
    </td>
    
    <td>
      The base URL, <code>
        --gcp-workload-provider
      </code>
      
      , <code>
        --gcp-service-account
      </code>
      
       if any, <code>
        --region
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        azure-openai-federated
      </code>
    </td>
    
    <td>
      <code>
        federated
      </code>
      
       with Entra, scope <code>
        https://ai.azure.com/.default
      </code>
      
      ; <code>
        openai-chat
      </code>
      
       and <code>
        openai-responses
      </code>
      
      ; no stored key
    </td>
    
    <td>
      The base URL, which the servers must name with <code>
        --cloud-endpoint entra-resources=
      </code>
      
       (<a href="https://docs.oz0.ai/fleet/cloud-federation">
        Cloud federation
      </a>
      
      ), <code>
        --entra-directory
      </code>
      
      , <code>
        --client-id
      </code>
      
      , <code>
        --region
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        azure-foundry-federated
      </code>
    </td>
    
    <td>
      The same, for Claude in AI Foundry, in <code>
        anthropic-messages
      </code>
    </td>
    
    <td>
      The base URL, directory, client and region
    </td>
  </tr>
</tbody>
</table>

```bash [Terminal]
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:

<table>
<thead>
  <tr>
    <th>
      Through
    </th>
    
    <th>
      Passed
    </th>
    
    <th>
      Refused, for example
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      Vertex AI
    </td>
    
    <td>
      <code>
        .../publishers/anthropic/models/&lt;model>:rawPredict
      </code>
      
       and <code>
        :streamRawPredict
      </code>
      
      ; <code>
        .../publishers/google/models/&lt;model>:generateContent
      </code>
      
      , <code>
        :streamGenerateContent
      </code>
      
       and <code>
        :countTokens
      </code>
      
      ; <code>
        .../endpoints/openapi/chat/completions
      </code>
    </td>
    
    <td>
      other publishers' models (<code>
        publishers/meta/...:rawPredict
      </code>
      
      ), <code>
        customJobs
      </code>
      
      , <code>
        batchPredictionJobs
      </code>
      
      , deployed endpoints' <code>
        :predict
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      Bedrock
    </td>
    
    <td>
      <code>
        model/&lt;id>/invoke
      </code>
      
       and <code>
        invoke-with-response-stream
      </code>
      
       for Anthropic's models (<code>
        anthropic.*
      </code>
      
      , a cross-region profile such as <code>
        eu.anthropic.*
      </code>
      
      , or a foundation model's or system inference profile's ARN); <code>
        openai/v1/chat/completions
      </code>
    </td>
    
    <td>
      other vendors' models (<code>
        meta.*
      </code>
      
      , <code>
        amazon.*
      </code>
      
      , <code>
        cohere.*
      </code>
      
      ), Converse, <code>
        async-invoke
      </code>
      
      , guardrails, an application inference profile's or a provisioned model's ARN, which does not say its model
    </td>
  </tr>
</tbody>
</table>

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](https://platform.claude.com/docs/en/build-with-claude/prompt-caching#cache-storage-and-sharing).

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](https://docs.oz0.ai/spend/models-and-keys/deployment-pools#pools-of-deployments) 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](https://docs.oz0.ai/build/apps/remote-agents/sessions-and-limits#where-the-keys-are) receive their provider credentials on the node and send requests to the provider themselves.

<span id="why-nodes-never-see-keys">

Why nodes never see keys

</span>

: [Gateway provider keys](https://docs.oz0.ai/spend/models-and-keys/authentication#why-gateway-provider-keys-stay-on-the-edge).


## Sitemap

See the full [sitemap](https://docs.oz0.ai/sitemap.md) for all pages.
