# Auth and secrets

> How Fermix stores OAuth tokens and API keys, resolves channel secrets from the OS keychain, and verifies plugin signatures with cosign.

This page explains where Fermix keeps your credentials and how it reads them. It separates two concerns: provider OAuth tokens (the access tokens you get from logging in with an account, instead of pasting an API key) live in `~/.fermix/auth.json` (mode `0600`, a file permission that lets only your user read it; owned by the daemon, the background Fermix service); channel and MCP secrets are read from your OS keychain at boot. API keys can also arrive from environment variables or directly from `config.toml`.

## Token store (auth.json)

`~/.fermix/auth.json` is a JSON document with the shape:

```json
{
  "version": 2,
  "providers": {
    "openai_codex": { ... }
  }
}
```

`Auth.Store` enforces strict file hygiene:

- Writes are atomic (all-or-nothing, so a crash mid-write cannot leave a half-written file): the new content is written to a temp file, `chmod 0600` is applied, then the file is renamed into place.
- `validate_permissions!/1` runs at daemon startup and fails loudly if the file mode is not `0o600`. The daemon refuses to start.
- If the file is malformed at write time, `preserve_and_refuse/3` renames the broken file to `auth.json.broken.<unix_ts>` and aborts the write rather than overwriting it.

`auth.json` stores tokens for every OAuth-authenticated provider: `openai_codex`, Anthropic (`anthropic_oauth`), xAI (`xai_oauth`), and all connected OAuth plugins (Google, GitHub, Notion, X, and others). It is created by `fermix setup` when any OAuth provider or plugin is configured. Plugins that authenticate with a static API key (Slack, Discord, AgentMail) keep their credential in the OS keychain, not in `auth.json` — see [Plugin API keys](#plugin-api-keys) below.

## Codex OAuth (openai_codex)

`openai_codex` is a distinct provider from `openai`. Instead of a fixed API key, you log in through your browser: Fermix opens a sign-in page and briefly listens on your own machine to catch the result (a PKCE loopback flow, a standard secure browser login that keeps the exchange on `127.0.0.1`).

The flow:

1. `fermix auth login` opens a browser to `auth.openai.com` and starts a local listener at `127.0.0.1:1455`.
2. The callback delivers the authorization code to that listener.
3. `Auth.TokenManager` exchanges the code, writes the token pair to `auth.json`, and thereafter refreshes tokens proactively before they expire.
4. Refresh-token rotation is single-use. Fermix maintains its own grant so it does not conflict with the Codex CLI over a shared token chain.

`Auth.TokenManager` handles proactive refresh across all OAuth providers, not only `openai_codex`.

## Provider API keys

For providers that use API keys rather than OAuth (`openai`, `anthropic`, `xai`, `mistral`, `openrouter`), Fermix resolves the key in this order:

1. Environment variable (`OPENAI_API_KEY`, `MISTRAL_API_KEY`, etc.), read from the daemon's own environment — your shell when you run `fermix run`, or the installed background service unit.
2. The `api_key` value under `[fermix_core.providers.<provider>]` in `config.toml`. If the value is `@keyring` (a placeholder standing in for the real secret, telling Fermix to fetch it from the OS keychain rather than storing it in the file), it is resolved through the configured OS keyring helper at load time.

For headless or container deployments, environment variables are the appropriate path. For interactive installs, `fermix setup` collects keys during the wizard and writes them to the OS keychain; no environment variable is needed afterward.

See [providers and models](/docs/providers-and-models) for the full provider list and model configuration, and [configuration](/docs/configuration) for `config.toml` key reference.

### Transcription backend keys

Voice-note transcription keys live under `[fermix_core.transcription]`. `openai_api_key` and `xai_api_key` override the reused chat-provider key, or fall through to it when unset; Deepgram has no chat provider to reuse and requires its own `deepgram_api_key`. SpaceXAI's native speech-to-text endpoint requires an API key — a Grok subscription OAuth token does not authorize it, even when the chat provider is running on OAuth.

Saved through setup (the Transcription card, or `fermix setup --transcription-api-key`), these keys ride the same secure-on-save path as provider keys: the plaintext goes to the OS keychain and `config.toml` keeps an `@keyring` sentinel. `fermix doctor`'s `transcription` row reports offline whether the active backend's credential resolves.

## OAuth providers (Anthropic, xAI, plugins)

Anthropic (Claude subscription) and xAI (Grok) support OAuth in addition to API keys. Both set `auth_mode = "oauth"` in `config.toml` and require a daemon restart to take effect. `fermix auth logout` reverts `auth_mode` back to `api_key`.

- **xAI**: `fermix auth login --provider xai` (loopback PKCE, profile `xai_oauth`). A 403 response means the Grok plan lacks API access, not a stale token.
- **Anthropic**: `fermix auth login --provider anthropic` using `--setup-token`, `--import-claude-code`, or `CLAUDE_CODE_OAUTH_TOKEN` (profile `anthropic_oauth`).

`fermix auth status` and `fermix auth logout` both accept `--provider` to target a specific provider.

Plugin OAuth providers (Google, GitHub, Notion, X, and others declared in `[fermix_core.oauth.<provider>]`) also go through the PKCE loopback engine. Each OAuth provider uses a dedicated loopback port. Client id and secret are configured via the setup page's per-provider form and stored in the OS keychain. Plugin OAuth tokens are managed with `fermix plugins auth login|reauthorize|refresh|logout NAME`.

Refresh dispatch across all OAuth providers is deduplicated through the registry so concurrent tool calls do not trigger redundant refresh requests.

### Plugin API keys

Not every plugin uses OAuth. Some authenticate with a static API key — a bot token or service key the operator pastes once. The wave-1 api_key plugins are **Slack** (bot token; OAuth scaffolding is retained but deferred for `search.messages`), **Discord** (bot token), and **AgentMail**.

- `fermix plugins auth set NAME VALUE` stores the credential; `fermix plugins auth clear NAME` removes it.
- The secret is keychained, never written to `config.toml` in plaintext: it is persisted under `[fermix_core.plugin_secrets]` as an `@keyring` sentinel keyed by the plugin name, and resolved from the OS keychain at boot.
- Until the key is set, the plugin reports status `needs_secret` (rather than vanishing). Setting it clears the status on the daemon's next turn.

The web setup Plugins page exposes the same set/clear flow for api_key plugins alongside the OAuth connect buttons.

## Stale token detection

An OAuth login you set up but then leave unused can quietly go stale: a provider or plugin sits idle long enough that its access token passes expiry with no activity to trigger a refresh. (Actively-used connections refresh their token on access, so this only affects genuinely dormant ones.) A token more than an hour past its `expires_at` is treated as stale and flagged *before* Fermix next tries to use it, so a dormant integration does not fail silently the next time it is selected. Staleness surfaces in three places:

- **Setup provider page** — the account's "Connected" badge switches to **"Reconnect needed"**.
- **`fermix doctor`** — an offline `auth tokens` check (no network, runs without `--full`) reports either `no stale OAuth tokens` or `stale, re-auth may be needed: <names>`.
- **Web setup "Final checks"** — an `Auth tokens` line reads `Reconnect needed: <names>`.

The fix is to reconnect the flagged connection: `fermix auth login --provider <name>` for a provider, or `fermix plugins auth reauthorize <name>` for a plugin.

## Channel secrets

Channel tokens (Telegram bot token, WhatsApp app secret, Slack signing secret, Discord bot token, etc.) live in each channel's own config block — for example `bot_token` under `[fermix_channels.telegram]`. When you save them through `fermix setup`, the plaintext value is written to the OS keychain and `config.toml` keeps only a `@keyring` sentinel in its place; Fermix resolves those sentinels back to the real secret at boot through the OS keyring helper (the same secure-on-save mechanism used for provider keys). Channel tokens are never exposed to sandboxed subprocesses.

The Telegram bot token is config-owned and is never read from the environment. A stray `TELEGRAM_BOT_TOKEN` export does not override it. The other channels (WhatsApp, Discord, Slack, Signal) can alternatively take their tokens from environment variables — see the [reference below](#environment-variable-reference) — which is the appropriate path for headless deployments.

## OS keyring helpers (SecretWriter)

`fermix setup` writes secrets into the OS keychain using whichever platform-native helper is present (auto-selected, with a short per-call timeout):

| Platform | Tool |
|----------|------|
| macOS | `security add-generic-password` (account `fermix`) |
| Linux | `secret-tool store` (libsecret; label "Fermix") |

On macOS, items are stored with an open access list (the `-A` flag), so the headless daemon can read them without a login-keychain password prompt. macOS only applies that ACL when an item is *created*, so every write deletes the existing item and re-adds it rather than updating it in place — an item first stored with a restrictive ACL (an older Fermix, a manual Keychain entry, or a past "Always Allow") is repaired the next time Fermix actually writes that secret. Saving an unchanged secret is not a write: setup keeps the existing keychain item and rewrites only a value that has genuinely changed, so re-running `fermix setup` does not by itself repair a stored item's ACL. The trade-off is that any process running as your user can read the item without a prompt: no weaker than leaving the secret in plaintext in `config.toml`, and it is still keychain-stored.

If no supported helper is found, Fermix does not silently fall back to storing secrets in the clear. An unchanged plaintext secret already sitting in `config.toml` is left in place, with a warning to run `fermix setup --migrate-secrets`; but saving a *new or changed* secret fails loudly (`could not be stored in the OS keyring: no supported OS secret helper is available`) rather than writing it to disk in plaintext.

### Profile namespacing

Keychain entries are namespaced by the optional `[fermix_core] profile` key in `config.toml`. On macOS the account is `fermix`; the service key is `fermix:<ENV>` for the default (`general`) profile, or `fermix:<profile>:<ENV>` for any named profile. For example, a `profile = "dev"` install stores `TELEGRAM_BOT_TOKEN` under service `fermix:dev:TELEGRAM_BOT_TOKEN`.

This lets separate Fermix installs (for example `~/.fermix` and `~/.fermix-dev`) maintain independent keychain entries without collision. The profile must be set before running `fermix setup` for a given home directory. Changing the profile after secrets are written orphans the old keychain entries: there is no migration for a profile change — re-run `fermix setup` to re-write secrets under the new namespace. (`fermix setup --migrate-secrets` is a different operation: it migrates plaintext `config.toml` secrets into the OS keychain, and does not touch profile-namespaced entries.)

## Log redaction

All log output — the file log and the console, OTP crash reports included — passes through a redacting formatter that masks credential-shaped tokens before they are written. Each match is replaced with a `[REDACTED:<vendor>]` marker naming the pattern that matched: `openai` (any `sk-` key, which also covers Anthropic's `sk-ant-…` keys — they are marked `openai` too, not `anthropic`), `github` (`ghp_`/`gho_`/`ghu_`/`ghs_`/`ghr_`/`github_pat_`), `slack` (`xox…`), `aws` (`AKIA…` key ids), `xai` (`xai-`), `google` (`AIza…`), `telegram` (bot tokens), and `bearer` (`Bearer` headers).

A secret that reaches a log line — or an unforeseen crash dump — is therefore masked rather than written in the clear. It is a backstop, not a substitute for redacting at the source.

## Realtime voice

The Realtime voice companion always authenticates with an OpenAI API key, even when the text provider is `openai_codex`. If your Realtime account differs from your text completion account, configure the key separately. Otherwise `fermix setup` reuses the same key for both.

## Plugin signature verification (cosign)

Before running a plugin, Fermix checks a cryptographic signature to confirm it came from a trusted source and was not altered. This requires [`cosign`](https://github.com/sigstore/cosign) (the signing tool that performs the check) on the daemon's `PATH`. Fermix verifies each plugin's signature before activation and refuses unsigned or tampered artifacts. Install it with:

```bash
brew install cosign
```

If `cosign` is not found, the install pipeline reports `cosign not found` rather than `signature invalid`, so the two error conditions are distinguishable. The daemon itself runs fine without `cosign` when no plugins are being installed.

`fermix upgrade` also uses `cosign verify-blob` to verify each release artifact before swapping in the new binary. See [distribution and upgrade](/docs/distribution-and-upgrade) for the full upgrade sequence.

## Environment variable reference

The table below covers credential and secret-adjacent variables. For a complete list of runtime variables see [configuration](/docs/configuration).

| Variable | When needed |
|----------|-------------|
| `OPENAI_API_KEY` | Provider is `openai`, or Realtime voice is enabled |
| `ANTHROPIC_API_KEY` | Provider is `anthropic` with `auth_mode = "api_key"` |
| `XAI_API_KEY` | Provider is `xai` with `auth_mode = "api_key"` |
| `CLAUDE_CODE_OAUTH_TOKEN` | Anthropic OAuth import (alternative to `--import-claude-code`) |
| `OPENROUTER_API_KEY` | Provider is `openrouter` |
| `MISTRAL_API_KEY` | Provider is `mistral` |
| `WHATSAPP_ACCESS_TOKEN` | WhatsApp channel enabled |
| `WHATSAPP_PHONE_NUMBER_ID` | WhatsApp channel enabled |
| `WHATSAPP_VERIFY_TOKEN` | WhatsApp channel enabled |
| `WHATSAPP_APP_SECRET` | WhatsApp channel enabled |
| `DISCORD_BOT_TOKEN` | Discord channel enabled |
| `DISCORD_BOT_USER_ID` | Discord channel enabled |
| `SLACK_BOT_TOKEN` | Slack channel enabled |
| `SLACK_SIGNING_SECRET` | Slack channel enabled |
| `SIGNAL_ACCOUNT` | Signal channel enabled |

These variables are only required for headless or container deployments that inject secrets through the environment. A configured install that went through `fermix setup` stores all secrets in the OS keychain and needs none of them set.
