Skip to main content

Security 5 - Credentials vault and scopes

API keys, OAuth tokens, database connection strings - every real Digitorn app needs at least one secret. The naive way is to drop them in env vars and pull them through {{env.X}} at compile time. That's fine for a single-user dev daemon. The moment more than one person or app shares a daemon, env vars stop scaling: the same value resolves for everyone, and rotating a key means a redeploy.

The credentials vault is the structured alternative: every secret lives in an encrypted store instead of baked into the compiled bundle.

Scopes​

The schema accepts three scope values (system_wide, per_app_shared, per_user) on credential.scope, but today only per_user is actually implemented end to end: every vault entry is stored against the user who added it (user_id + provider_name), and the other two values compile without error but don't change resolution - there's no system-wide or app-shared credential store behind them yet.

Resolution for a brain.credential is by (user_id, provider), not by ref: at session start the daemon looks up the signed-in user's vault entry for that provider name and takes the most recently updated one. ref and scope are accepted in the YAML (and shown back in the UI) but aren't consulted during lookup - if a user stores two credentials for the same provider, whichever was saved or edited most recently wins, regardless of which ref the app declared.

yaml
agents:
- id: main
brain:
provider: deepseek
backend: openai_compat
credential:
ref: deepseek_main
scope: per_user
provider: deepseek

The schema's other job​

security.credentials_schema declares what an app expects - the client (chat UI, install flow) reads it and renders a typed form so a user installing the app can fill in exactly the secrets it needs:

yaml
security:
credentials_schema:
providers:
- name: notion_main
label: "Notion workspace"
type: oauth2
scope: per_user
oauth_provider: notion # pre-registered OAuth flow
- name: stripe_secret
label: "Stripe API key"
type: api_key
scope: per_app_shared
fields:
- name: api_key
type: secret
required: true
validation_regex: "^sk_(live|test)_[a-zA-Z0-9]{24,}$"

The user sees an install page with two fields: an OAuth button for Notion and a text input for the Stripe key, validated against the regex before it's submitted. type: oauth2 skips field rendering entirely - the client opens the provider's authorization page and the daemon handles the token exchange.

There's no compile-time cross-check between an agent's credential.ref and the names declared in credentials_schema - the two are independent; a typo'd ref surfaces at runtime, as a "credential missing" resolution failure when the session starts, not as a compile error.

Encryption​

Every credential field is encrypted with NaCl secretbox (XSalsa20-Poly1305) under a single local key, ~/.digitorn/server.key - created with 0600 permissions the first time the daemon needs it. There's no KMS backend or environment-variable override; the key lives on the machine running the daemon. See Credentials reference → Security architecture for the exact format.

Resolution at runtime​

When a session starts, the daemon resolves the brain's credential by looking up (user_id, provider) in the vault and taking the most recently updated matching entry. If nothing matches, the session fails with a structured "credential missing" error instead of attempting the LLM call and hitting a cryptic 401.

It's resolved at session start and hot-swapped onto the live LLM provider instance; the agent loop never sees a stale key. This path is the same regardless of which scope the YAML declares - see Scopes above.

The full lifecycle - OAuth refresh, the built-in provider catalog, the credential type list - is documented in Credentials reference.

Picking a scope today​

per_user is the only scope with real behavior behind it right now: each user stores and resolves their own credential for a given provider, independent of every other user on the daemon. Declare per_app_shared or system_wide if that's the intent you want to record in the YAML, but don't rely on the daemon to enforce app- or install-wide sharing yet - it resolves identically to per_user.

Going further​

  • Full credentials reference (credential types, the built-in provider catalog, OAuth flow): Credentials.
  • The legacy {{secret.X}} / {{env.X}} template form still works as a dev fallback but doesn't get the typed install-form schema: see the migration example in Credentials → YAML reference.
  • How this fits the rest of the gate chain - credential resolution happens as part of turn setup, before any tool-call gate runs: Security 2 - Gates.