Credentials¶
One question decides where a credential lives: whose account is it?
- Bot authenticates as itself → global credential, injected at deploy time via TOML config or env var override.
- Bot accesses a user's account on their behalf → per-user credential, stored in the encrypted
secretstable (orgoogle_oauth_tokensfor OAuth flows).
Global credentials (bot identity)¶
These belong to the Istota instance, not to any user. They live in config.toml (or env var overrides) and are loaded once at startup. The credential proxy strips them from Claude's environment and injects them only into authorized skill subprocesses.
| Credential | Config section | Env var override | Consumed by |
|---|---|---|---|
| SMTP (email sending) | [email] |
ISTOTA_EMAIL_SMTP_PASSWORD |
email skill |
| IMAP (email receiving) | [email] |
ISTOTA_EMAIL_IMAP_PASSWORD |
email skill |
| CalDAV | derived from [nextcloud] |
ISTOTA_NEXTCLOUD_APP_PASSWORD |
calendar, location skills |
| Nextcloud | [nextcloud] |
ISTOTA_NEXTCLOUD_APP_PASSWORD |
nextcloud skill |
| GitLab token | [developer] |
ISTOTA_DEVELOPER_GITLAB_TOKEN |
developer skill |
| GitHub token | [developer] |
ISTOTA_DEVELOPER_GITHUB_TOKEN |
developer skill |
| Google OAuth client secret | [google_workspace] |
ISTOTA_GOOGLE_WORKSPACE_CLIENT_SECRET |
Google OAuth flow |
| Web OAuth2 client secret | [web] |
ISTOTA_WEB_OAUTH2_CLIENT_SECRET |
Nextcloud login flow |
| Web session signing key | [web] |
ISTOTA_WEB_SESSION_SECRET_KEY |
Session cookies |
ISTOTA_SECRET_KEY |
env only | ISTOTA_SECRET_KEY |
Fernet encryption for tier-2 secrets |
CalDAV credentials are derived from the Nextcloud app password automatically — no separate config needed.
ISTOTA_SECRET_KEY is the master encryption key for the secrets table and google_oauth_tokens. It must be at least 32 characters; the key is scrypt-derived into a Fernet key at runtime. Generate with python3 -c "import secrets; print(secrets.token_hex(32))".
Provisioning global credentials¶
Bare metal (Ansible): Set values in your Ansible vars or /etc/istota/settings.toml. Sensitive values go into secrets.env (via istota_use_environment_file: true), which systemd injects as env vars — keeping them out of the config file on disk.
Docker: Set values in docker/.env. The entrypoint auto-generates ISTOTA_SECRET_KEY on first start (persisted to /data/.secret_key).
Manual: Edit config.toml directly, or set ISTOTA_* env vars in the service unit's EnvironmentFile=.
Per-user credentials (user's accounts)¶
These belong to individual users. Stored in the secrets table with Fernet encryption at rest (keyed from ISTOTA_SECRET_KEY). Users configure them via the web UI at /istota/settings or via CLI:
Connected services¶
Available to all users regardless of which modules are enabled.
| Service | Keys | Consumed by |
|---|---|---|
| Karakeep | base_url, api_key |
bookmarks skill |
| Google Workspace | (OAuth flow — tokens in google_oauth_tokens table) |
google_workspace skill |
| ntfy | topic, server_url*, username*, password*, token* |
push notifications |
* = optional
Module services¶
Gated by module enablement. Appear on per-module settings pages.
| Module | Service | Keys | Consumed by |
|---|---|---|---|
| money | Monarch Money | session_id*, csrftoken* |
money skill (transaction sync via cookie auth) |
| feeds | Tumblr | tumblr_api_key* |
feeds skill (Tumblr feed ingestion) |
| location | Overland | ingest_token |
location skill (GPS ingestion webhook) |
* = optional
Google OAuth tokens¶
A special case: stored in their own google_oauth_tokens table (not in secrets) because the OAuth flow writes access_token and refresh_token as a pair with expiry metadata. Fernet-encrypted at rest using the same ISTOTA_SECRET_KEY. A migration function auto-upgrades any pre-existing plaintext rows on read.
Users connect their Google account through the web dashboard at /istota/ (the dashboard shows a Google Workspace card). See Google Workspace for the full setup.
How credentials flow at runtime¶
config.toml / env vars / encrypted secrets table
│
▼
build_skill_env(skill_index, ctx) ← walks every skill manifest, resolves each EnvSpec
│
▼
_split_credential_env(env, derive_credential_set(skill_index))
│ │
│ └──▶ Claude subprocess (clean_env — no secrets)
▼
SkillProxy(credential_env, derive_skill_credential_map(...), derive_lookup_allowlist(...))
│
▼
credential-fetch <VAR> ← skill CLI requests a specific var
│ proxy checks the per-skill credential map
▼ and the lookup allowlist (minus _PROXY_LOOKUP_BLOCKED)
skill subprocess env
The credential set, per-skill scope, and lookup allowlist are all derived from skill manifests by four pure helpers in executor.py:
| Helper | Returns |
|---|---|
derive_credential_set(skill_index) |
every env var declared with sensitive: true across all skills |
derive_authorized_skills(selected, skill_index, ctx) |
selected skills ∪ skills whose sensitive EnvSpecs actually resolve under this task's context |
derive_skill_credential_map(authorized, skill_index) |
per-skill credential map (proxy uses this to scope injection) |
derive_lookup_allowlist(authorized, skill_index) |
vars the proxy will respond to over credential-fetch, minus _PROXY_LOOKUP_BLOCKED |
There is no longer a hand-maintained _PROXY_CREDENTIAL_VARS constant or _CREDENTIAL_SKILL_MAP in code. Adding a credential is a manifest edit; everything else falls out of derive_*.
Authorization is decoupled from skill selection. A skill is authorized for credential access whenever its sensitive credentials actually resolve for this user — not when the skill is selected into the prompt. This prevents keyword-miss lockouts: if a user has Karakeep configured, the bookmarks skill can always request KARAKEEP_API_KEY at runtime, even if "bookmark" wasn't in the prompt. Doc-only skills like developer (no CLI module) are eligible too — they consume credentials via credential-fetch from helper scripts the skill's setup_env hook bind-mounts into the sandbox.
Auto-authorization passes fallbacks_disabled=True to the resolver: an instance-wide EnvironmentFile fallback for an operator-set value cannot fan out and auto-authorize every user, defeating the per-user privacy posture.
For more on the proxy architecture, PID-scoped socket paths, and rejection logging, see security: credential proxy.
Credential proxy variables¶
The proxy strips these env vars from the Claude subprocess and injects them server-side. The list is manifest-derived (every EnvSpec with sensitive: true); today's set:
CALDAV_PASSWORDNC_PASSSMTP_PASSWORDIMAP_PASSWORDKARAKEEP_API_KEYGOOGLE_WORKSPACE_CLI_TOKENGITLAB_TOKENGITHUB_TOKENMONARCH_SESSION_ID,MONARCH_CSRFTOKENNTFY_TOKEN,NTFY_PASSWORDTUMBLR_API_KEYISTOTA_SECRET_KEY— routed to module-skill subprocesses that need to decrypt per-user secrets, but blocked at the lookup endpoint via_PROXY_LOOKUP_BLOCKEDsocredential-fetch ISTOTA_SECRET_KEYfrom inside Claude is rejected
See environment variables for the complete env var reference.
Adding credentials for new integrations¶
When adding a new service integration, follow this decision tree:
- Who authenticates? If the bot logs in as itself (a service account, a bot token), it's global. If it accesses a user's personal account, it's per-user.
- Global → add the field to the relevant config dataclass +
[section]inconfig.toml, declare the env var in the consuming skill'sskill.mdenv:block withfrom: "config",sensitive: true, and an optionalfallback_varforEnvironmentFileoverrides. The proxy strip-set, auth map, and lookup allowlist update automatically viaderive_*. - Per-user → add the service and keys to
secret_schema.py(connected service or module service), then declare the env var in the consuming skill'sskill.mdenv:block withfrom: "secret"(andsensitive: trueif it's a credential rather than a host/URL). For complex setup (e.g.,developer's git credential helper), usefrom: "setup_env"and write asetup_env(ctx) -> dict[str, str]hook in the skill's__init__.py. - OAuth → if the service uses OAuth, consider a dedicated table (like
google_oauth_tokens) or store the refresh token as a regular secret. OAuth flows need a web UI endpoint for the redirect dance.
For the full skill development workflow including env var mapping, see adding skills.
Edge cases¶
ntfy — could go either way. The bot could have one global ntfy topic and broadcast to all users. Instead, it's per-user: each user picks their own topic and optionally their own server. This scales better for multi-user and lets users opt out or use self-hosted ntfy.
CalDAV — currently global (one service account with shared calendar access via Nextcloud). If Istota ever supports users bringing their own CalDAV servers, this would need a per-user path.
Browser — BROWSER_API_URL and BROWSER_VNC_URL are deployment-level config, not credentials. They point to the headless browser container.