# How credentials are configured

In a self-hosted config, credential fields hold variable names, not values. What that buys you, and what the validator catches.

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

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

Config files get committed. That is the whole problem: the file describing which providers you use
is the file most likely to end up in a repository, a container image, a support ticket or a
screenshot, and a provider key inside it is a key you have to rotate.

This page covers self-hosted configuration. In the hosted product, use a
[saved provider connection](/docs/build/provider-connections): its key is stored encrypted, while
your application sends only its TokenTriage runtime credential. Replacing that saved key uses a new
candidate, a provider check and explicit activation.

So no TokenTriage config field holds a credential. Six fields hold the *name* of an environment
variable, and the process resolves the value at startup.

```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      # the NAME of a variable, not its value
```

The consequence is worth stating plainly: this file can be committed. It describes your topology
and reveals nothing you would have to rotate.

**That sentence is about one mode, so say which.** A config file you run yourself holds
references, and nothing in it has to be rotated. A hosted gateway can instead hold the provider key
as a [saved provider connection](/docs/build/provider-connections), and there the secret is stored
encrypted at rest, each version sealed under its own data key wrapped by a customer-managed KMS key
in TokenTriage's cloud account, with no API, export or log that reads it back. Request-supplied mode
is the third case and it keeps the no-storage guarantee: the caller sends a provider key on each
request and no copy is persisted. Which mode applies is gateway configuration, not a fallback a
request can pick.

## The six fields

| Field | Used by | Holds |
|---|---|---|
| `api_key_env` | `bearer`, `header`, and as a short-circuit on `aws_sigv4` and `gcp_oauth` | a variable name |
| `auth.access_key_id_env` | `aws_sigv4` | a variable name |
| `auth.secret_access_key_env` | `aws_sigv4` | a variable name |
| `auth.session_token_env` | `aws_sigv4`, optional | a variable name |
| `auth.credentials_file_env` | `gcp_oauth` | a variable name, whose value is a file *path* |
| `auth.private_key_file_env` | `snowflake_jwt`, and `oci_sig` | a variable name, whose value is a file *path* |

The sixth row is the newest: it arrived with the key-file schemes, and it goes through the same
env-reference guard as the other five rather than getting a rule of its own. The last two rows are
a level of indirection further out, and worth reading twice: the variable holds a path, the file at
that path holds the key material, and neither the path nor the key appears in a response, a log
line or the admin status payload.

## What happens when the field is absent

An upstream with no `api_key_env` forwards the inbound `Authorization` header unchanged. Your
client's own credential reaches the provider, and the gateway adds nothing.

That is a supported deployment, not a degraded one. It is what you want when each caller already
holds their own key and you are running TokenTriage to see the cost rather than to hold the
credential. It is also what you get by accident when you name a variable that is not set, which is
why both `doctor` and the boot banner call it out:

```console
warning: upstream "openai" references OPENAI_API_KEY, which is not set — requests use the credential your client sends, and get the provider's 401 if it sends none
```

<Callout type="warning">
That banner line covers `api_key_env` only. An upstream whose credential arrives through
`auth.credentials_file_env`, the AWS pair or `auth.private_key_file_env` boots silently when those
variables are unset, and the first sign is the provider's own error. Run `tokentriage doctor`
before you start, and read its `keys` line knowing what it counts: five of the six fields. It does
not count `auth.private_key_file_env`, so a key-file upstream can contribute no reference to that
line at all. The surface that covers every scheme is the admin status payload, where the same
upstream reads `state: missing_env`.
</Callout>

## What the validator catches

Every one of the six fields goes through the same check, and the check is narrower than it sounds.
It rejects any value that is not a legal environment-variable name: a leading letter or underscore,
then letters, digits and underscores. A key with a hyphen, a dot, a slash or whitespace in it fails.

```console
$ tokentriage validate --config tokentriage.yaml
Error: configuration is invalid: config tokentriage.yaml: upstreams["u"].api_key_env: this looks like a secret value, not a reference — it must name an environment variable (e.g. TT_SLACK_WEBHOOK), not contain the value itself
```

The `config tokentriage.yaml:` segment is the path you passed, so yours will read whatever you
named your file. **The binary prints the whole line twice**, because `validate` reports the error
and the top-level handler reports it again; the second copy is cut from the block above. One error
arriving as two does not mean your config carries two problems.

That catches the two most common credential shapes in this market, because OpenAI and Anthropic
keys both carry hyphens.

**It does not catch a key whose characters are all legal in a variable name.** A Groq key
(`gsk_…`), an underscore-separated key (`sk_live_…`) and an AWS access key id
(`AKIAIOSFODNN7EXAMPLE`) are each valid identifiers, so each passes validation and would be
committed as written. The check is a syntax guard on the reference, not a secret scanner, and it
errs toward admitting.

Two things follow. Do not treat a green `validate` as confirmation that a config is free of
secrets, and put a secret scanner in your commit path if that matters to you. The value of this
model is that the *correct* configuration has nothing to leak, not that the incorrect one is
impossible to write.

<Details summary="Why not accept an inline secret and encrypt it?">

Every scheme for holding a secret in the config file needs a key to decrypt it, which moves the
problem rather than solving it, and it makes the file no longer safe to paste into a bug report.
Naming a variable keeps the file boring, and hands secret storage to whatever already holds your
other secrets — a systemd unit, a Kubernetes secret, a cloud parameter store, a `.env` your shell
sources.

</Details>

## When the credential is a key file

`snowflake_jwt` is the scheme that introduced the sixth field, and a single built-in descriptor
declares it: `snowflake`. The gateway reads a PEM-encoded RSA private key once at startup, mints an
RS256 token from it, and refreshes that token off the request path. What travels on the wire is a
token the gateway made, not a secret you pasted.

The config around it stays plain, because none of it is secret:

```yaml
upstreams:
  snowflake:
    provider: snowflake
    base_url: https://myorg-myaccount.snowflakecomputing.com
    account: MYORG-MYACCOUNT      # a period here is refused, not normalised
    user: SVC_TOKENTRIAGE
    auth:
      private_key_file_env: SNOWFLAKE_PRIVATE_KEY_PATH
```

`account` and `user` are identity coordinates rather than credentials: they become the token's
issuer and subject claims and travel to the provider in cleartext, so a committed file is the right
place for them. The account-scoped host is your `base_url`, not a field of its own. Only the key is
held out, and it is held out through the two hops the table above describes.

Two details of that block bite before anything else does. A period in `account` is refused rather
than rewritten, because the vendor's prose and the vendor's own sample generator disagree about
what to do with one. And `user` is the account's login name, which can differ from the name the
same user is listed under; a mismatch there produces a 401 that reads like a bad key.

The same field appears against `oci_sig`, the second key-file scheme. No built-in descriptor
declares that one, and the Oracle Cloud descriptor that does ship uses `bearer` with an API key, so
`auth.private_key_file_env` reaches a provider today through Snowflake.

<Callout type="warning">
**A passphrase-protected private key is not supported, and the gateway reports it rather than
failing strangely.** An encrypted PKCS#8 key (`BEGIN ENCRYPTED PRIVATE KEY`) and an encrypted
PKCS#1 key (a `Proc-Type: 4,ENCRYPTED` header) are each detected and reported through the
credential health field as `unreadable_credentials`, with no error message carrying your key's
path. The scheme then fails open: nothing is injected, and the provider answers with its own 401.
Generate the key unencrypted, which is what the vendor's primary instructions already produce
(`openssl genrsa 2048 | openssl pkcs8 -topk8 -inform PEM -out rsa_key.p8 -nocrypt`), and protect
the file with filesystem permissions. This is a Snowflake-shaped problem rather than an Oracle
Cloud one, because the shipped `oci` descriptor reads no key file at all.
</Callout>

Supporting a passphrase would mean a passphrase credential field, and that is a second credential
class in a config schema that is frozen. It is a schema change, not a data change, which is why it
is a stated gap rather than a backlog item.

## Where the credential goes

Only outward. The resolved value is applied by the authenticator when the request is forwarded, and
it is not written to the decision ledger, the admin plane, an export or a trace. What surfaces
instead is a scheme name and a state:

```json
"snowflake": {
  "auth_scheme": "snowflake_jwt",
  "auth_health": {
    "configured": true,
    "fresh": true,
    "state": "ok"
  }
}
```

Those are the bytes a `GET /tt/api/status` read returned for the Snowflake upstream configured
above, booted against the vendor's own hostname and dialled zero times. Three sibling fields on the
same object are cut here because they are not about credentials: `base_url`, `dialect` and
`in_cooldown`. The upstream name is the capture's; the scheme and the health values belong to the
descriptor and its authenticator, so yours read the same.

`configured` and `fresh` are booleans about presence and currency. `state` is one of six values
listed on the [authentication schemes page](/docs/build/providers-auth). None of them is a
free-form string, which is what stops a credential fragment reaching a payload through an error
message.

## Whose key is it

There are three answers, and they are different features.

**The operator's key**, named in `api_key_env` and shared by every caller. This page.

**The caller's key**, forwarded because no `api_key_env` is set. Also this page, and the default
when the field is absent.

**A tenant's own key**, held per virtual key so each customer's traffic bills to their provider
account. That is a governance feature with a disclosure attached to the rows it produces, and it is
documented under [virtual keys](/docs/build/governance-keys).

## What can go wrong

| Symptom | Cause | Fix |
|---|---|---|
| provider returns 401 to every request | referenced variable unset, nothing forwarded | export it; `doctor` or the status payload names it |
| `this looks like a secret value` | a key literal in a `*_env` field | put the variable name there |
| `the multi-part auth.* fields are not valid here` | an `auth:` block on a bearer or header provider | use `api_key_env` |
| status shows `unreadable_credentials` | the file is missing, malformed, or an encrypted key | check the path the variable resolves to; re-generate the key with `-nocrypt` |
| status shows `ok` and the provider still 401s | a resolved credential is not an accepted one | `ok` says material loaded, nothing more |
| status shows `expired` | refresh has been failing long enough for the token to lapse | check provider reachability and clock skew |

<NextSteps items={[
  {title: "Authentication schemes", href: "/docs/build/providers-auth", description: "Which scheme consumes the variable you named here, and what it does with it before a request goes out."},
  {title: "Virtual keys", href: "/docs/build/governance-keys", description: "Per-tenant credentials, when each customer should bill to their own provider account."},
  {title: "Security", href: "/docs/operate/security", description: "The wider handling story: what is stored, what is redacted, and what leaves the process."}
]} />
