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 0600is applied, then the file is renamed into place. validate_permissions!/1runs at daemon startup and fails loudly if the file mode is not0o600. The daemon refuses to start.- If the file is malformed at write time,
preserve_and_refuse/3renames the broken file toauth.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:
fermix auth loginopens a browser toauth.openai.comand starts a local listener at127.0.0.1:1455.- The callback delivers the authorization code to that listener.
Auth.TokenManagerexchanges the code, writes the token pair toauth.json, and thereafter refreshes tokens proactively before they expire.- 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:
- Environment variable (
OPENAI_API_KEY,MISTRAL_API_KEY, etc.), read from the daemon’s own environment — your shell when you runfermix run, or the installed background service unit. - The
api_keyvalue under[fermix_core.providers.<provider>]inconfig.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, profilexai_oauth). A 403 response means the Grok plan lacks API access, not a stale token. - Anthropic:
fermix auth login --provider anthropicusing--setup-token,--import-claude-code, orCLAUDE_CODE_OAUTH_TOKEN(profileanthropic_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 VALUEstores the credential;fermix plugins auth clear NAMEremoves it.- The secret is keychained, never written to
config.tomlin plaintext: it is persisted under[fermix_core.plugin_secrets]as an@keyringsentinel 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 offlineauth tokenscheck (no network, runs without--full) reports eitherno stale OAuth tokensorstale, re-auth may be needed: <names>.- Web setup “Final checks” — an
Auth tokensline readsReconnect 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.