Authentication and credentials
Zuno keeps two things apart that other tools blur: which provider a model route belongs to, and where that provider's credential comes from. A provider entry in zuno.json is a catalog and a transport choice. A credential is a separate object, stored outside configuration by default, resolved at request time.
Providers and credentials is authoritative for provider transports, login methods per provider, and the request path. This page covers the configuration surface and the storage model.
Two paths to a credential
An API key is the general case. Zuno stores it under a provider id and sends it as a bearer credential to the configured endpoint.
OAuth is provider-specific. The built-in openai provider owns the ChatGPT login, its refresh protocol, the ChatGPT endpoint rewrite, and the account header. Setting transport: "openai" on a custom provider does not grant it that flow. A custom OAuth provider needs its own registered login method and a request-side consumer; an OAuth-shaped JSON object alone is not an integration.
zuno auth methods openai
zuno auth login openai --method chatgpt-browser
zuno auth login openai --method chatgpt-device
printf '%s' "$OPENAI_API_KEY" | zuno auth login openai --method api-key2
3
4
zuno auth is an alias of zuno providers. Listing methods first is worth the extra command: a configured provider id receives only the API-key method when its resolved native transport actually consumes that credential, and an arbitrary or credential-only id is rejected before Zuno reads standard input.
In an interactive terminal, zuno auth login can also create the provider before it authenticates it. The setup surface is intentionally product-level rather than transport-level: OpenAI, Amazon Bedrock, and OpenAI-compatible. Native Responses endpoints use the OpenAI transport; OpenAI-compatible defaults to Chat Completions. The generated entry is validated and atomically merged into the global configuration without replacing unrelated settings.
Bedrock is a provider-specific bearer flow backed by the same api credential shape:
zuno auth methods amazon-bedrock
printf '%s' "$AWS_BEARER_TOKEN_BEDROCK" |
zuno auth login amazon-bedrock --method bedrock-bearer-token2
3
The stored value becomes Authorization: Bearer for Bedrock requests. Without a bearer token, Zuno does not require an auth-store entry and uses the AWS SDK credential chain.
Declaring where credentials come from
| Key | Type | Default | Description |
|---|---|---|---|
provider.<id>.env | string[] | null | none | Environment variables that supply this provider's credentials |
provider.<id>.api | string | null | none | Base API URL for the provider |
provider.<id>.id | string | null | the map key | Provider id override |
provider.<id>.name | string | null | none | Display name |
provider.<id>.transport | enum | null | none | Native request transport implemented by Zuno |
provider.<id>.surface | chat | responses | messages | null | none | Default request surface for this provider's models |
provider.<id>.options | object | null | none | Provider-level options, including SDK options this schema does not name |
provider.<id>.headers | object of string | null | none | Default extra HTTP headers for every model in this provider |
provider.<id>.models | map of model | null | none | Per-model configuration and overrides |
provider.<id>.whitelist | string[] | null | none | Models to keep, to the exclusion of the rest |
provider.<id>.blacklist | string[] | null | none | Models to drop |
env is a list, not a single name, and the first non-empty variable wins. That is what lets one provider entry work across machines that name the same secret differently:
{
"provider": {
"myopenai": {
"name": "My OpenAI gateway",
"transport": "openai",
"surface": "responses",
"env": ["MYOPENAI_API_KEY", "OPENAI_API_KEY"],
"options": { "baseURL": "https://gateway.example.com/v1" }
}
}
}2
3
4
5
6
7
8
9
10
11
Precedence
Credential resolution is ordered and has no hidden fallback:
provider.<id>.options.apiKey, including an explicitly empty string;- the matching entry in
auth.json; - the first non-empty variable declared by
provider.<id>.env; - no credential.
An explicitly empty apiKey therefore wins and yields no credential. That is deliberate — it gives you a way to prove a provider is unauthenticated rather than silently picking up an ambient environment variable.
An environment key is consumed directly and never copied into auth.json. This is why a provider can already be authenticated on a fresh machine where nobody ran a login command.
For Bedrock, the exact precedence is AWS_BEARER_TOKEN_BEDROCK, then a non-empty options.apiKey, then the matching stored API credential, then the AWS SDK credential chain. Other AWS environment variables configure that chain; they are not bearer tokens.
Where credentials live
| What | Path |
|---|---|
| Credential store | $XDG_DATA_HOME/zuno/auth.json, normally ~/.local/share/zuno/auth.json |
| Mode on Unix | 0600 |
| Override | ZUNO_AUTH_CONTENT replaces credential reads with a JSON object |
ZUNO_AUTH_CONTENT is the mechanism for ephemeral and managed environments — containers, CI, a secret manager that injects at start. When credentials come from that variable, Zuno does not persist rotated OAuth tokens back to disk, because there is no file it owns.
The variable is withheld from the shell tool, so a command the model composes cannot read the injected credentials. The whole ZUNO_* namespace is withheld, not just this variable, which closes the same leak by the other route: an inline provider.<id>.options.apiKey supplied through ZUNO_CONFIG_CONTENT is a provider credential too, and it is withheld as well. A nested zuno started from inside such a command therefore inherits neither, and resolves configuration and credentials the ordinary way, which means it needs its own credential store or configuration. Plan for that in a container that supplies credentials only through the environment. See Tools.
Putting apiKey directly in zuno.json is supported but exposes a secret to configuration backups and source control. Prefer the credential store or an injected ZUNO_AUTH_CONTENT. If you do use options.apiKey, keep it out of any layer that gets committed; see Variables and substitution for reading a value from a file or environment instead.
When the credential file is damaged or newer than this build
A credential file that exists but holds no store — zero bytes, or nothing but whitespace, which is what an interrupted write or a truncation from outside leaves behind — no longer fails every command that touches it. A read reports an empty store together with the damage it found, so zuno auth list, zuno auth login and the model catalogue keep working, and the next write publishes a whole store over it. The finding is logged at error level once per file per process: the credentials that were in it are gone, and logging in again is not the same as getting them back, so restore a backup if those credentials mattered.
An entry Zuno cannot decode is kept, not dropped. A credential written by a newer Zuno, a hand edit, or an authentication shape this build does not model would otherwise be deleted by the first zuno auth login for any other provider, with the loss visible only in a log line. The same holds one level down: a field a newer Zuno added to an entry this build does understand is written back as it was found — on the entry being changed as well as on every entry the write never touched.
Two omissions there are deliberate. zuno auth login <provider> replaces that provider's credential outright, so unrecognized fields carried on the credential it replaced are dropped rather than re-attached: a device binding or a rotation stamp issued for the old token is not a claim about the new one, and a newer Zuno writing the entry again restores them. And a field this build does model is always written from the value Zuno holds, so clearing a credential really clears it.
Preservation is not a promise about bytes. A preserved entry is republished as the same JSON value, re-encoded by Zuno, so indentation and the key order inside the object are the encoder's.
An entry this build cannot decode is not yet reachable from the command line: zuno auth list does not list it, zuno auth logout answers Unknown configured provider, and zuno mcp logout refuses while any entry in mcp-auth.json is undecodable. Removing such an entry means editing the file. No Zuno write ever deletes it.
Inspecting without leaking
zuno auth listThis prints active credential kinds, the storage path, and the matching environment variable names, without printing secret values. A stored credential with no current login-capable provider route is retained and labelled orphan so you can remove it with zuno auth logout.
ChatGPT OAuth stores an access token, refresh token, expiry, and account id in the same file. Before a request, Zuno refreshes a token that is near expiry and persists the rotated tokens, unless credentials came from ZUNO_AUTH_CONTENT.
Codex and Claude Code product subagents are separate. They inherit the native command's existing login, never appear in zuno auth login, and their credentials are not copied into auth.json.
Which providers are enabled
| Key | Type | Default | Description |
|---|---|---|---|
enabled_providers | string[] | null | none | When set, the only providers to enable |
disabled_providers | string[] | null | none | Providers to drop even when their credentials are present |
disabled_providers is the answer to "an ambient environment variable is authenticating a provider I do not want in this project". It drops the provider even though the credential resolves.