Authentication schemes
Every scheme a descriptor can declare, what each one needs in your config, and what the credential health states are saying.
On this page
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 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 prints the evidence cell by cell.
bearer
The default, and the one most providers use.
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.
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.
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.
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.
none
Nothing is injected. Used by the 8 self-hosted and declared-free descriptors, where the server is on your own network.
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:
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.
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.
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.
"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 |