Skip to main content
Every AI integration needs API keys — for your LLM provider, for speech-to-text, for web search, and more. Comis gives you three ways to manage these secrets, from simple environment variables for getting started to a fully encrypted secrets store for production. This page walks you through each option, helps you choose the right one, and shows you how to set up the encrypted store step by step.

Three Ways to Store Secrets

Comis supports three methods for managing secrets, each with different security trade-offs. You can use any combination of these methods, and you can upgrade from one to another at any time without downtime.
MethodSecurityBest ForSetup
Environment variables (.env)BasicGetting started, developmentEasy
SecretRef in configBetterReferencing env vars from YAMLEasy
Encrypted secrets storeBestProduction deploymentsAuto-generated on first boot

Environment Variables (.env)

The simplest approach and the one most people start with. Create a .env file in your Comis data directory and set variables like OPENAI_API_KEY=sk-.... Comis loads .env automatically on startup. This is the fastest way to get running, but secrets are stored in plain text on disk — anyone with file access can read them.
~/.comis/.env
OPENAI_API_KEY=sk-proj-abc123...
ANTHROPIC_API_KEY=sk-ant-abc123...
DEEPGRAM_API_KEY=dg-abc123...
Plain text .env files are fine for local development, but not recommended for production. Anyone with access to the file can read your secrets.

SecretRef in Config

Instead of putting secrets directly in your YAML config, you can reference secrets from three different sources using the SecretRef syntax. This keeps your config file free of actual secrets, making it safer to share, back up, or commit to version control. The simplest form references an environment variable:
~/.comis/config.yaml
providers:
  anthropic:
    apiKey: ${ANTHROPIC_API_KEY}   # Resolved from environment at runtime
  openai:
    apiKey: ${OPENAI_API_KEY}      # Resolved from environment at runtime
For more advanced cases, Comis supports three resolution providers:
ProviderSyntaxWhat it does
envenv:provider/KEY_NAME or ${KEY_NAME}Reads from the SecretManager (env vars or encrypted store)
filefile:provider/path/to/fileReads an absolute path on disk (max 1MB, no symlinks to restricted paths). Append #/json/pointer to extract a field from a JSON file.
execexec:provider/idInvokes a credential helper binary via JSON-RPC (10s default timeout)
~/.comis/config.yaml
# Read Anthropic key from a sealed file deployed alongside the daemon
providers:
  anthropic:
    apiKey: file:vault/etc/comis/secrets.json#/anthropic/api_key

# Pull AWS keys from a credential helper (e.g., aws-vault, pass)
channels:
  s3-uploader:
    accessKeyId: exec:aws-vault/AWS_ACCESS_KEY_ID
    secretAccessKey: exec:aws-vault/AWS_SECRET_ACCESS_KEY
The ${VARIABLE_NAME} form tells Comis to look up the value from the SecretManager at startup. The actual secret never appears in the YAML file itself. The file: and exec: forms keep secrets out of .env files entirely — they live wherever your secret store keeps them. Comis includes a built-in encrypted secrets store that uses AES-256-GCM encryption with HKDF-SHA256 key derivation. Secrets are encrypted at rest in a dedicated database file. Even if someone gains access to your server files, they cannot read the secrets without the master key. This is the recommended approach for any deployment where security matters — which includes most production environments. The encrypted store provides:
  • Encryption at rest — Secrets are never stored in plain text on disk. Each encryption uses a fresh 32-byte salt and 12-byte IV; the algorithm is versioned via an HKDF info string (comis-secrets-v1) so it can be rotated without breaking older ciphertexts.
  • Key derivation — The master key is never used directly; a derived key is created for each operation using HKDF-SHA256.
  • Integrity verification — AES-256-GCM provides authenticated encryption via a 16-byte tag, so tampered data is detected and rejected.
  • In-memory cache — Decrypted values live in process memory (defensive copies, not shared references) so repeated reads do not require repeated decryption. The cache is invalidated on rotation and cleared on shutdown.

Encrypted Store (Auto-Generated)

The encrypted secrets store is enabled by default. On first boot, Comis auto-generates a 32-byte master key and writes it to ~/.comis/.env as SECRETS_MASTER_KEY=<hex> (file mode 0600, owner-read-only). Backup obligation: The key in ~/.comis/.env is the only copy. Losing it makes secrets.db permanently unreadable (AES-256-GCM, no key escrow). Back up this file to a secure location immediately after first boot.

Opting Out

To run without the encrypted store, set the storage mode in config.yaml:
security:
  storage: env   # read-only, reads .env / process.env only (no encrypted store)
security.storage: file keeps a plaintext-at-0600 store instead. The daemon emits a startup WARN when a non-encrypted storage mode is active.

End-to-End Walkthrough: Store, Scope, Rotate, Delete

Here is a complete example of managing an OpenAI API key for a single agent named customer-support, from initial storage to rotation to deletion.
1

Store the secret

Add the key to the encrypted store. The CLI prompts interactively so the value never appears in your shell history.
node packages/cli/dist/cli.js secrets set OPENAI_API_KEY
# Enter value: ******** (paste the key, press Enter)
# Stored: OPENAI_API_KEY (encrypted)
2

Scope it to one agent

Edit your config so only the customer-support agent can read the key:
~/.comis/config.yaml
agents:
  customer-support:
    secrets:
      allow:
        - "OPENAI_API_KEY"
  # Other agents have no `secrets.allow` set, so they cannot read this key
  # (assuming you have set an allow list for them too -- empty defaults to
  # unrestricted, with a one-time warning).
Reload the config: node packages/cli/dist/cli.js config reload.
3

Verify the scope

Trigger a read from the wrong agent and confirm it is denied. Watch the audit log:
node packages/cli/dist/cli.js logs follow --filter "secret:accessed"
# ... agentId=analytics secretName=OPENAI_API_KEY outcome=denied
# ... agentId=customer-support secretName=OPENAI_API_KEY outcome=success
Every access — successful, denied, or not-found — emits a secret:accessed audit event tagged with the agent ID.
4

Rotate the key

When the upstream provider issues a new key, write it over the old one. Comis re-encrypts on write and invalidates its in-memory cache:
node packages/cli/dist/cli.js secrets set OPENAI_API_KEY
# Enter value: ******** (paste the new key)
# Updated: OPENAI_API_KEY (re-encrypted, cache invalidated)
Existing agent sessions pick up the new value on the next read — no daemon restart required.
5

Delete the secret

When you decommission the integration, remove the key from the store:
node packages/cli/dist/cli.js secrets delete OPENAI_API_KEY
# Removed: OPENAI_API_KEY
The encrypted ciphertext is wiped from disk and the cache entry is cleared. Any agent that subsequently calls secret.require("OPENAI_API_KEY") will throw with a diagnostic error message containing the key name.

Per-Agent Secret Access

By default, every agent can access all secrets in the store. While this is convenient for getting started, it means a compromised agent could potentially access secrets it does not need. For better security, restrict each agent to only the secrets it needs using glob patterns. This follows the principle of least privilege — each agent only gets the secrets it requires for its specific role.
~/.comis/config.yaml
agents:
  customer-support:
    secrets:
      allow:
        - "openai_*"             # Only OpenAI-related secrets
        - "ANTHROPIC_API_KEY"    # And the Anthropic key
  analytics:
    secrets:
      allow:
        - "GOOGLE_*"             # Only Google-related secrets
If an agent tries to access a secret that does not match its allow patterns, the request is denied and an audit event is recorded. This means a compromised or misbehaving agent cannot access secrets it does not need.
An empty allow list means unrestricted access (for backward compatibility). Once you add any pattern to the list, only matching secrets are accessible to that agent.

Pattern Matching

The allow list supports standard glob patterns. Patterns are case-sensitive, so make sure they match the exact casing of your secret names:
PatternMatchesDoes Not Match
openai_*openai_key, openai_orgOPENAI_KEY (case-sensitive)
ANTHROPIC_*ANTHROPIC_API_KEYanthropic_key
*_API_KEYOPENAI_API_KEY, GOOGLE_API_KEYopenai_key

Auditing Your Secrets

Regular auditing helps you catch configuration drift and ensure your secrets are properly protected. Run comis doctor to check your secrets configuration. The audit examines:
  • Plaintext secrets in config files — Secrets that should be migrated to the encrypted store or referenced via SecretRef. A field that already uses a ${VAR} env-substitution reference or a structured SecretRef is recognized as properly configured and is not flagged.
  • Known provider API keys in .env files — Provider-specific keys (like OpenAI, Anthropic, Deepgram) that should be in the encrypted store for production
  • File permission issues — Config and secrets files that have overly permissive access (readable by other users on the system)
  • Missing master key — The encrypted store is enabled but the SECRETS_MASTER_KEY environment variable is not set
  • Unencrypted backup copies — Backup files that may contain unencrypted secret data
The audit produces actionable findings with specific remediation steps. For example, if it finds a plaintext API key in your config, it tells you exactly how to migrate it to the encrypted store.
Run comis doctor after any configuration change to verify that your secrets are still properly managed. It is also a good practice to include it in your deployment checklist.

Configuration Reference

All secrets-related settings in your config file:
SettingDefaultDescription
security.storage"encrypted"Credential storage backend for all stores: encrypted (AES-256-GCM SQLite), file (plaintext at 0600), or env (read-only, .env/process.env). The secrets database is always <data dir>/secrets.db.
agents.<name>.secrets.allow[] (unrestricted)Glob patterns for allowed secret names
The SECRETS_MASTER_KEY environment variable is auto-generated on first boot and written to ~/.comis/.env (mode 0600). You can provide it explicitly to override the auto-generated value. This key is never stored in the config file itself.
To disable the encrypted store entirely, set security.storage: env (or file) in config.yaml. The daemon then boots in that mode and emits a startup WARN. Use env only if you need to manage secrets exclusively via .env files.

Provider API Keys

When configuring LLM providers via the providers_manage tool, API keys follow the same SecretManager workflow:
  1. Store the key: gateway({ action: "env_set", env_key: "NVIDIA_API_KEY", env_value: "nvapi-..." })
  2. Reference in provider config: providers_manage({ action: "create", provider_id: "nvidia", config: { apiKeyName: "NVIDIA_API_KEY", ... } })
The provider configuration stores only the key name (apiKeyName), never the key value itself. The actual key is resolved at runtime via SecretManager. For local providers like Ollama that don’t require API keys, omit apiKeyName entirely.

Broker Injection (Network Boundary)

For exec tools that drive API-key CLIs, the credential broker provides a second injection path that keeps the key out of the sandbox entirely. Two injection paths for exec tools:
secretRefs (env injection)Credential broker (network injection)
What it doesInjects the secret as an env var inside the exec sandboxInjects the secret at the network boundary (TLS termination)
Secret visibilityEnv var is visible inside the sandbox (echo $MY_KEY)Placeholder only inside sandbox; real key never enters
Use whenSimple scripts needing a key as an environment variableAPI-key CLIs (Claude Code, curl with auth, Finnhub SDK)
PlatformAll platformsLinux broker-only egress is R1-gated; TLS injection works everywhere
For step-by-step broker setup, see Credential Broker →.

OAuth Credential Storage

OAuth profiles for subscription-based providers (currently OpenAI Codex) are stored separately from the read-only SecretManager interface documented above. The OAuth credential store is mutable because tokens refresh on a 30-minute cadence and the rotated refresh token must be persisted before the lock holding the in-flight refresh releases. Two storage backends are supported, selected by security.storage:
ModePathHot-reloadThreat posture
encrypted (default)oauth_profiles table inside secrets.dbNo — SQLite WAL masks eventsAES-256-GCM ciphertext per row
file~/.comis/auth-profiles.jsonYes — chokidar 100ms debounceFilesystem perms only (0o600)

File mode (security.storage: file)

  • Path~/.comis/auth-profiles.json (under COMIS_DATA_DIR if overridden).
  • Permissions — mode 0o600 is enforced on every write. Files created with weaker perms are rejected at boot.
  • Atomic write — the daemon writes to a temp file, fsyncs, then renames into place. Crashes mid-write cannot leave a half-written file visible. When the daemon runs under node --permission, the fsync call is skipped (the fd-based fsync API is disabled by the permission model — see Node Permissions reference). Writes remain atomic (tmp -> rename) but are best-effort durability under --permission.
  • Cross-process locking — multiple daemons or the daemon + a comis auth login CLI all refreshing the same profile coordinate via proper-lockfile. The refresh-then-persist critical section runs under the lock; the lock is released only after the rotated refresh token is on disk.
  • Hot-reload — a chokidar watcher with 100ms debounce surfaces external writes (e.g., comis auth login from a different shell) to the running daemon without restart.

Encrypted mode (security.storage: encrypted)

  • Storage — the oauth_profiles table inside the same SQLite database as secrets.db.
  • Algorithm — each row stores the full profile JSON as one AES-256-GCM ciphertext blob with per-row 12-byte IV and 16-byte auth tag. The denormalized expires_at epoch-millisecond column is the only plaintext field, kept for fast comis doctor scans.
  • Key derivation — shares the SecretsCrypto engine with the encrypted secrets store; HKDF-SHA256 derives a fresh per-row key from the master.
  • RequirementSECRETS_MASTER_KEY must be set; the daemon fails fast on boot otherwise.
  • Hot-reload limitation — SQLite WAL writes do not surface as change events on the database file; CLI-written profiles require a daemon restart to take effect.

Email semi-redaction in logs

Profile identities are emails (e.g., user@example.com). Comis logs never include the access or refresh token at any level (Pino auto-redaction is the safety net; the canonical fields list in CLAUDE.md excludes them). Emails are semi-redacted before logging using a first-char + last-char per atom strategy: user@example.com becomes u***@e***.com. This preserves enough identity to debug a multi-account routing issue while denying log readers the full email. The redaction is performed at the structured-log call site, not by Pino’s redaction list, so it survives even if the log call accidentally interpolates the email into the message string. Verified by tests/log-redaction-canary.test.ts.

Threat model

  • T-OAUTH-DISK-EXFILDisk-read attacker. An attacker with read access to ~/.comis/ could exfiltrate stored access and refresh tokens. Mitigation: Plaintext mode enforces 0o600 perms (user-only read). Encrypted mode stores tokens as AES-256-GCM ciphertext with per-row IV/auth-tag, defeating raw-byte scans (verified by canary tests).
  • T-OAUTH-REFRESH-RACEConcurrent refresh race. Two daemon instances or daemon+CLI refreshing the same token simultaneously could result in refresh_token_reused (which auto-locks the account at OpenAI). Mitigation: Per-profile cross-process file lock via proper-lockfile with 5-retry exponential backoff; refresh persistence happens before lock release, so concurrent callers see the rotated token via the lock-protected critical section.
  • T-OAUTH-ENV-DRIFTEnv-var drift. An operator updates OAUTH_OPENAI_CODEX in .env after a profile is already in the store; the daemon silently uses the stored profile, leading to confusion. Mitigation: WARN-once-per-(provider, process) when the env-var refresh-token differs from the stored profile, with hint: "env-override-ignored" and errorKind: "config_drift". Operators should run comis auth logout --profile <id> then restart the daemon to re-bootstrap from env, OR delete the env var.
For the full PKCE flow, refresh semantics, and wizard-vs-CLI parity matrix, see OAuth concepts.

Output Guard for Secret Egress

writeSecretGuard controls what happens when the daemon detects a secret-shaped value in a file write path (for example, a tool writing a .env.example or a test fixture). This prevents a careless agent from writing a live API key to disk in cleartext. Configure via security.writeSecretGuard in config.yaml:
~/.comis/config.yaml
security:
  writeSecretGuard: warn   # warn (default) | block | off
ValueBehavior
warn (default)Write proceeds with scrubbed content and a redirect hint logged. Safe for .env.example files and hex SHAs.
blockWrite is rejected when secret-shaped values are detected. Use in high-security environments where no plaintext secrets should ever touch disk.
offNo write-time secret scan. Use only if you have an external DLP tool or the false-positive rate on your workload is unacceptable.
block mode carries a false-positive risk on legitimate files that contain hex strings or long random tokens (e.g., test fixtures, SSH public keys). Test your agent workloads before enabling block in production.
Source: packages/core/src/config/schema-security.ts:82z.enum(["warn", "block", "off"]).default("warn").optional()

Migrating from Pre-v1.5 Configuration

In v1.5, the unified security.storage key replaced three legacy keys that are now removed from the schema. If your config.yaml or .env still contains any of these, the daemon will fail boot immediately with:
Config validation failed: security: Unrecognized key(s) in object: 'oauth'
The three removed keys are:
  • oauth.storage (under security:)
  • security.secrets.enabled
  • COMIS_DISABLE_ENCRYPTED_SECRETS (in .env)
Because AppConfigSchema uses z.strictObject, any unrecognized key triggers a VALIDATION_ERROR and the daemon refuses to start — there is no silent fallback.

Migration steps

Step 1 — Detect legacy keys:
grep -n "oauth\.storage\|secrets\.enabled" ~/.comis/config.yaml
grep -n "COMIS_DISABLE_ENCRYPTED_SECRETS" ~/.comis/.env
If either command returns results, continue to step 2. If both return nothing, your config is already v1.5-compatible. Step 2 — Remove all three legacy keys:
  • Delete any oauth.storage: line under security: in config.yaml
  • Delete any security.secrets.enabled: line from config.yaml
  • Delete any COMIS_DISABLE_ENCRYPTED_SECRETS= line from .env
Step 3 — Choose a storage mode and add security.storage: to config.yaml:
~/.comis/config.yaml
security:
  storage: encrypted    # AES-256-GCM SQLite -- production default
  # storage: file       # plaintext 0o600 files -- simple or dev setups
  # storage: env        # read-only from process.env -- zero-file deployments
If choosing encrypted, ensure SECRETS_MASTER_KEY is set in .env. If it is not set, run comis secrets init to generate one. Step 4 — Verify boot:
node packages/cli/dist/cli.js secrets list
If the daemon starts and returns a list (empty or with keys), migration is complete. Step 5 — Check for stranded credentials: At boot, the daemon logs a WARN if the inactive backend still holds credentials the active mode cannot reach:
  • "Inactive encrypted secrets store has real credentials" — emitted when switching from encrypted to file or env
  • "Inactive file secret store has real secrets" — emitted when switching from file to encrypted
Each WARN includes a hint field with manual migration steps. Run comis secrets list after the storage-mode switch, then manually re-import any stranded credentials.

Live Credential Apply

Storing, rotating, or deleting a credential mid-conversation takes effect immediately — no daemon restart required. When you run comis secrets set <NAME> or comis secrets delete <NAME>, the daemon writes to the credential store and then upserts (or removes) the value in the shared in-memory MutableSecretManager Map in the same RPC call. Both the broker and exec agents read from that Map on every request, so the next tool invocation observes the new value with no restart. The RPC response carries "restarting": false to confirm:
{ "name": "ANTHROPIC_API_KEY", "stored": true, "restarting": false }

What applies live vs. what requires a restart

Credential typeCLI commandLive?Note
Named secretscomis secrets set <NAME>YesApplies on the next broker/exec request
Named secrets (delete)comis secrets delete <NAME>YesRemoved from Map immediately
MCP OAuth tokenscomis mcp loginYesToken written to credential store and applies live
OAuth profiles (file mode)comis auth loginYeschokidar hot-reload picks up the new profile
OAuth profiles (encrypted mode)comis auth loginNoSQLite WAL masks file-change events; daemon restart needed
The encrypted-mode OAuth limitation is a known constraint of using SQLite WAL for storage. For workloads that rotate OAuth profiles frequently, consider security.storage: file or triggering a graceful daemon restart after comis auth login.
Source: packages/daemon/src/api/secrets-handlers.ts:399-414, 579-596; packages/core/src/security/secret-manager.ts:81-129

Defense in Depth

How secrets fit into the security layers

Hardening

Complete security hardening checklist

Secret Manager Reference

Technical reference for the secret manager

Configuration

Config.yaml setup guide

OAuth

Subscription-based authentication for Codex