Docs/Operations/Auth and secrets

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:

{
  "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 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 for the full provider list and model configuration, and 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 — 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 (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:

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 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.

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.

Next steps