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.
On this page
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: 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.
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, 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:
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
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.
$ 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.
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.
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:
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.
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:
"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. 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.
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 |