# Authentication schemes

Every scheme a descriptor can declare, what each one needs in your config, and what the credential health states are saying.

Source: https://tokentriage.com/docs/build/providers-auth

---
import Callout from '../../../components/Callout.astro';
import Details from '../../../components/Details.astro';
import NextSteps from '../../../components/NextSteps.astro';

Each provider descriptor declares one authentication scheme, and that choice decides which config
fields your upstream needs. Get the pairing wrong and validation says so before you start the
binary, naming both the scheme and the fields it expects.

This build implements 7 authentication schemes, and 6 of them are declared by a shipped
descriptor. One further name, `query`, is reserved and rejected.

Every scheme here describes a self-hosted upstream, whose credential arrives from the environment
at startup while the config holds only the variable's name. A hosted [saved provider
connection](/docs/build/provider-connections) carries its provider's scheme with it, and there the
secret is stored encrypted at rest under a customer-managed KMS key in TokenTriage's cloud account,
readable by nothing you or we can call. Request-supplied mode stores no copy at all: the caller's
key serves that one request. So a storage claim on this page is a claim about this mode.

| Scheme | Credential arrives as | Config fields | Descriptors |
|---|---|---|---|
| `bearer` | `Authorization: Bearer <key>` | `api_key_env` | 61 |
| `gcp_oauth` | a self-signed service-account JWT | `auth.credentials_file_env`, or `api_key_env` | 13 |
| `header` | a named header carries the key | `api_key_env` | 10 |
| `none` | nothing is injected | none | 8 |
| `aws_sigv4` | a Signature Version 4 signature | `auth.access_key_id_env` + `auth.secret_access_key_env`, or `api_key_env` | 2 |
| `snowflake_jwt` | an RS256 JWT signed with your key pair | `auth.private_key_file_env` + `account` + `user` | 1 |
| `oci_sig` | request signing, with no signer behind it | `auth.private_key_file_env` + four identifiers | 0 |

The right-hand column counts, out of the 95 built-in provider descriptors TokenTriage compiles in,
how many declare each scheme. It is derived from the descriptor registry by the same generator that
writes the provider matrix, so it moves when the registry does. That generator names the test behind
each gated provider-by-endpoint cell and prints the ungated ones rather than hiding them. **Ten descriptors carry no gated cell; the provider reference names each one.** Where a test does exist
it drives a request through the descriptor against a mock shaped like that provider and checks that
the request was authenticated and forwarded, the response relayed byte for byte, usage extracted and
the price resolved. Those are mocks, not the providers' live endpoints.
[The provider reference](/docs/build/providers-reference) prints the evidence cell by cell.

## `bearer`

The default, and the one most providers use.

```yaml
upstreams:
  groq:
    provider: groq
    base_url: https://api.groq.com/openai   # /openai is Groq's — your client already sends /v1
    api_key_env: GROQ_API_KEY
```

**What it sends.** `Authorization: Bearer <the value of that variable>`, replacing whatever
credential the inbound request carried.

**With no `api_key_env`.** The inbound `Authorization` header is forwarded unchanged, so your
client's own key reaches the provider. This is a supported way to run, not an error.

**How it fails.** With the variable unset, requests are forwarded with the client's credential and
the provider answers. The boot banner prints a line naming the variable, and `doctor` reports
`missing_env` on its `keys` check.

**Oracle Cloud is on this scheme.** The `oci` descriptor takes an OCI Generative AI API key, which
Oracle states is different from an OCI IAM API key, so no request-signing scheme was built for it.
The key is region-scoped, and the regional host is your own `base_url`.

## `header`

Identical in shape to `bearer`, with the key written into a provider-specific header instead.

| Descriptor | Header | Value written |
|---|---|---|
| `anthropic` | `x-api-key` | the raw key |
| `azure_openai`, `azure_ai`, `azure_text` | `api-key` | the raw key |
| `gemini` | `x-goog-api-key` | the raw key |
| `elevenlabs` | `xi-api-key` | the raw key |
| `black_forest_labs` | `x-key` | the raw key |
| `assemblyai` | `Authorization` | the raw key |
| `deepgram` | `Authorization` | `Token ` then the key |
| `fal_ai` | `Authorization` | `Key ` then the key |

2 of the 10 carry a prefix inside the value: Deepgram's `Token ` and fal.ai's `Key `. The
trailing space is part of the wire, and it is descriptor data rather than a branch inside the
driver, so the next provider with a non-Bearer prefix is a row rather than a patch.

Whichever scheme injects a credential also strips the inbound carriers it supersedes:
`Authorization`, `x-api-key`, `api-key` and `x-goog-api-key` by default, so a client's own key does
not travel alongside the operator's.

## `aws_sigv4`

Bedrock signs each request rather than presenting a key, so it needs a region and a credential
pair.

```yaml
upstreams:
  bedrock:
    provider: bedrock
    base_url: https://bedrock-runtime.us-east-1.amazonaws.com
    region: us-east-1                       # signing scope and region-class pricing
    auth:
      access_key_id_env: AWS_ACCESS_KEY_ID
      secret_access_key_env: AWS_SECRET_ACCESS_KEY
      session_token_env: AWS_SESSION_TOKEN  # optional, for temporary credentials
```

**`region:` is required**, and validation says why: it is both the signing scope and the thing that
selects a region-qualified price. An upstream without it is turned away rather than signed wrongly.

**A bearer key short-circuits the whole scheme.** Set `api_key_env` alone, with no `auth:` block,
and a Bedrock API key is presented as a bearer token instead of signing.

**Signing keys are cached and refreshed off the request path.** A refresh failure keeps the last
working material and degrades the reported health rather than blocking traffic.

## `gcp_oauth`

Vertex AI mints a self-signed service-account JWT and presents it as a bearer token. The
environment variable holds the *path* to the key file, not the key.

```yaml
upstreams:
  vertex:
    provider: vertex_ai
    base_url: https://us-central1-aiplatform.googleapis.com
    params:
      project: my-gcp-project
      location: us-central1
    auth:
      credentials_file_env: GOOGLE_APPLICATION_CREDENTIALS
```

**The primary flow makes no network call.** The compact JWT is the token, so there is no
token-exchange round trip in the common case. The key file is read once, away from the request
path, and neither the path nor the key material appears in a response, a log or a status payload.

**`api_key_env` is accepted instead**, for the Gemini AI Studio style of key.

**When the self-signed flow cannot be used**, the scheme falls back to a token exchange and says so
in its health state rather than failing quietly.

## `snowflake_jwt`

Snowflake authenticates a key pair rather than a token, so this scheme signs a short-lived JWT with
your private key and presents it as a bearer credential. One descriptor declares it, and it is the
only scheme this build added.

```yaml
upstreams:
  snowflake:
    provider: snowflake
    base_url: https://myorg-myaccount.snowflakecomputing.com
    account: MYORG-MYACCOUNT
    user: SVC_TOKENTRIAGE
    auth:
      private_key_file_env: SNOWFLAKE_PRIVATE_KEY_PATH
```

**`account` and `user` are plain config, not secrets.** They become the JWT's `iss` and `sub`
claims and travel to Snowflake in cleartext, which is why a file like the one above can be
committed. `private_key_file_env` names an environment variable, and that variable holds the
*path* to the PEM, so the key material is two hops away from your config.

**The host is yours, not derived.** The account-scoped hostname comes from `base_url` and nothing
computes it from `account`. Two sources for one hostname would be one source too many.

**What it sends.** An RS256 JWT as `Authorization: Bearer`, minted and refreshed off the request
path, with a 59-minute lifetime renewed 5 minutes before it expires. A second header,
`X-Snowflake-Authorization-Token-Type: KEYPAIR_JWT`, rides along; that one is descriptor data
rather than a credential.

**A period in `account:` is turned away rather than normalised.** Snowflake's own prose replaces
periods with hyphens and Snowflake's own published sample generator truncates at the first period,
so `myorg.myaccount` becomes `MYORG-MYACCOUNT` under one rule and `MYORG` under the other. Picking
one silently would mint a JWT naming an account you did not write, and the only symptom would be a
401 that looks like a rotated key. Write the `<orgname>-<account_name>` form both rules agree on.

<Callout type="warning">
**A passphrase-protected private key is not supported, and this is the scheme where that bites.**
An encrypted PKCS#8 key (`BEGIN ENCRYPTED PRIVATE KEY`) and an encrypted PKCS#1 key (a
`Proc-Type: 4,ENCRYPTED` header) are both detected and reported as `unreadable_credentials`, with no
panic and no error message carrying your key's path. Nothing is then injected, and Snowflake answers
with its own 401. Generate the unencrypted key Snowflake's own instructions produce, with
`openssl genrsa 2048 | openssl pkcs8 -topk8 -inform PEM -out rsa_key.p8 -nocrypt`, and protect the
file with permissions rather than a passphrase.
</Callout>

## `none`

Nothing is injected. Used by the 8 self-hosted and declared-free descriptors, where the server
is on your own network.

```yaml
upstreams:
  ollama:
    provider: ollama
    base_url: http://127.0.0.1:11434
```

Credential references are tolerated and unused, so leaving one behind from an earlier config is not
an error.

## `oci_sig`, the scheme with no descriptor behind it

Request signing over an Oracle tenancy is implementable and no built-in descriptor declares it. The
count in the table above is zero, and it is zero for a reason worth reading before you reach for
this scheme.

**Your `oci` upstream does not use it.** The shipped `oci` descriptor authenticates with `bearer`
and a working API key, so an OCI upstream configured the ordinary way is authenticated in the
ordinary way. Nothing on this page changes that.

**The axis beneath the scheme ships; the signer was specified and then not built.** Declare
`auth: {scheme: oci_sig}` on a descriptor of your own and the config validates, the upstream boots,
and the authenticator loads your PEM and reports its health. What it does not do is sign anything:
it injects no credential at all, and the provider answers with its own 401. Validation demands 4
identifiers first, each with its own message naming why the signature needs it:

```text
upstreams["oci_sig_demo"].tenancy_ocid: required by the "oci_sig" scheme — it is a literal component of the request signature's keyId (App. B.3)
upstreams["oci_sig_demo"].user_ocid: required by the "oci_sig" scheme — it is a literal component of the request signature's keyId (App. B.3)
upstreams["oci_sig_demo"].key_fingerprint: required by the "oci_sig" scheme — it is a literal component of the request signature's keyId (App. B.3)
upstreams["oci_sig_demo"].compartment_ocid: required by the "oci_sig" scheme — every OCI Generative AI chat request carries a mandatory compartmentId body field (it is not part of the signature)
```

That capture is `tokentriage validate` on a config declaring an inline `oci_sig` provider with all
four identifiers omitted; the upstream name is this example's, and the rest is the binary's.

**The health state will read `ok`, and it means less than it looks like.** For this scheme `ok`
reports that the PEM parsed, not that a request will authenticate. Read it as a fact about the key
file. An encrypted key is reported as `unreadable_credentials` here too, but the passphrase is not
what stands between you and a 200: nothing is injected either way.

Why a scheme in this state exists at all is worth one sentence. An earlier build met a declared
scheme it could not construct and quietly fell back to a different credential under a different
scheme, which authenticated with something else and looked like it worked. That fallback is gone,
replaced by three named errors that say what they are declining to do. A declared-but-unbuilt scheme
that injects nothing and says so is the opposite failure direction from the one that was removed.

<Details summary="The reserved eighth name">

A `query` scheme, the key as a URL query parameter, is named in the vocabulary and turned away when
a descriptor declares it, at descriptor validation and again in the transport.

The objection is the carrier rather than the provider, and it is sharper here than the usual one
about access logs and browser history. A transport failure embeds the full outbound URL, query
string included, into the Go error it produces, and the pipeline persists that error into the
decision ledger's `outcome.error` field. A query credential would therefore end up written into
your own ledger by a failure path nobody thinks about. Every provider surveyed has a header
alternative, so the scheme buys nothing worth that.

</Details>

## Pairing errors

Validation checks the credential block against the descriptor's scheme, and the messages name both.

| What you wrote | What you get |
|---|---|
| `auth:` block on a `bearer` or `header` provider | `provider uses the "bearer" scheme (single credential via api_key_env); the multi-part auth.* fields are not valid here` |
| `credentials_file_env` on `aws_sigv4` | `not valid for the "aws_sigv4" scheme (use access_key_id_env/secret_access_key_env, or api_key_env alone)` |
| AWS fields on `gcp_oauth` | `the "gcp_oauth" scheme uses credentials_file_env or api_key_env; AWS-style access_key/secret fields are not valid here` |
| neither credential on `aws_sigv4` | `the "aws_sigv4" scheme requires access_key_id_env and secret_access_key_env (or api_key_env alone …)` |
| Bedrock without `region:` | `provider "bedrock" requires a region (aws_sigv4 signing scope / region-qualified pricing / a {region} URL template)` |
| AWS or GCP fields on `snowflake_jwt` | `the "snowflake_jwt" scheme uses private_key_file_env; the AWS/GCP credential fields are not valid here` |
| `snowflake_jwt` with no `account:` | `required by the "snowflake_jwt" scheme — it is a literal component of the JWT's iss/sub claims (App. B.4)` |

Neither key-file scheme has a bearer short-circuit. `aws_sigv4` and `gcp_oauth` both accept
`api_key_env` as an alternative; `snowflake_jwt` and `oci_sig` do not, because a private key is not
a token you can present in its place.

## Reading auth health

A running instance reports the scheme and a health state per upstream on the admin plane, as names
and states only. No value, path or token fragment appears.

```json
"upstreams": {
  "oci": {
    "base_url": "https://inference.generativeai.us-chicago-1.oci.oraclecloud.com/20231130/actions/v1",
    "dialect": "",
    "in_cooldown": false,
    "auth_scheme": "bearer",
    "auth_health": { "configured": true, "fresh": true, "state": "ok" }
  },
  "snowflake": {
    "base_url": "https://myorg-myaccount.snowflakecomputing.com",
    "dialect": "",
    "in_cooldown": false,
    "auth_scheme": "snowflake_jwt",
    "auth_health": { "configured": true, "fresh": true, "state": "ok" }
  }
}
```

That is the `upstreams` block of `GET /tt/api/status` from a booted instance with those two
upstreams configured, taken on 2026-08-24 and re-indented for reading; the payload also carries the
mode, the listen address, the tier names and the pricing snapshot. The `base_url` values are the
ones the config named, and the auth fields are the descriptors' and the authenticators'. `dialect` reads back empty on both
because the field echoes the `dialect:` key you wrote rather than the wire the descriptor resolved
to, and neither upstream needs to write one. Take a descriptor's wire from the provider reference
instead.

One value in that capture is a misconfiguration worth naming. The `oci` descriptor carries an
absolute chat path, `/20231130/actions/v1/chat/completions`, so the `base_url` above repeats that
prefix and the request forwards to it twice. Point an `oci` upstream at the bare regional host.

The state is a closed set of six values.

| State | Means | What to do |
|---|---|---|
| `ok` | the credential resolved and is current | nothing |
| `missing_env` | a referenced variable is unset | export it, or accept client-forwarded credentials |
| `unreadable_credentials` | the file is present but unreadable, malformed or passphrase-encrypted | check the path, and check the key is not encrypted |
| `refresh_failed` | a background refresh failed; the last good material is still serving | check the provider and your clock skew |
| `expired` | the cached token expired and could not be refreshed | the same, with urgency; requests now get the provider's error |
| `fallback_exchange_active` | `gcp_oauth` fell back to a token exchange | usually informational |

<Callout type="warning">
`auth_health` describes the credential, not the provider. `state: ok` means a credential resolved,
not that the provider answered; a provider that is down or rejecting your key still reports `ok`
here. Reachability shows up as `in_cooldown` on the same payload, and only after requests have
actually failed. Nothing probes your providers on your behalf.
</Callout>

<NextSteps items={[
  {title: "How credentials are configured", href: "/docs/build/providers-credentials", description: "Where the credential itself lives, given that none of these schemes let you put one in your config."},
  {title: "Azure OpenAI and Azure AI Foundry", href: "/docs/build/providers-azure", description: "The header scheme in a setup that also wants a deployment name inside the URL."},
  {title: "Amazon Bedrock", href: "/docs/build/providers-bedrock", description: "SigV4 end to end, plus the bearer short-circuit and what the region field is doing."},
  {title: "Gemini, Vertex AI and Model Garden", href: "/docs/build/providers-google", description: "The service-account JWT scheme in use, and which of the four Google doors needs it."}
]} />
