Secrets
Per-user encrypted credentials — when to use, federal model, Vault architecture, the seven invariants
Most extensions eventually need a credential the user supplies — a Spotify API key, an OpenAI key, a Stripe webhook signing secret, an OAuth refresh token. EXT-SECRETS-V1 (SDK v4.2.2+) is the federal way to declare those, store them encrypted, and read them in handlers without ever exposing plaintext to admins, logs, or backups.
When to use this — and when not to
Use @ext.secret for:
- User-supplied credentials your extension calls a third-party API with (Spotify API key, OpenAI key, GitHub PAT)
- OAuth refresh / access tokens your extension acquires after a user authorize flow
- Webhook signing secrets the user pastes from a provider dashboard (Stripe
whsec_*, GitHubsha256=...) - Any per-user value where "an admin reading the value would be a federal violation"
Don't use it for:
- Platform-level secrets shared by every user (e.g. a service-wide LLM key the platform pays for) — those live in env vars / systemd Environment overrides
- Configuration that isn't sensitive (panel layout preferences, language) — use
ctx.configorctx.store - Multi-step PII data that needs encryption at rest —
imperal_secretsstorage is sized for credentials (≤4 KB default), not arbitrary user data. Use platform encryption for larger PII.
Federal rule — ctx.config is NOT for credentials
This is the single most common mistake authors make when migrating an existing extension to EXT-SECRETS-V1: leaving a legacy read site that pulls a credential from ctx.config while wiring write sites through ctx.secrets. The Save UI confirms success, but the handler still reads the empty ctx.config slot and reports "credentials not configured". The user thinks the extension is broken; the platform looks broken; the credential was never the problem.
| Path | What lives there | Visible to | Encrypted at rest | Federal-grade |
|---|---|---|---|---|
ctx.config | Non-sensitive settings — default timezone, feature flags, display preferences | User, Panel UI, admin tooling (plaintext in user store) | No | No |
ctx.secrets | Credentials — API keys, OAuth tokens, webhook signing secrets | Only the handler call stack (briefly) and the platform KMS | Yes (AES-256-GCM, non-exportable key) | Yes — EXT-SECRETS-V1 |
Audit your read sites after declaring @ext.secret
Adding an @ext.secret(...) declaration + a ctx.secrets.get() call is only half the migration. You must also remove every legacy ctx.config.get(...), os.environ.get(...), and redis.hget(...) site that previously read the same credential. Otherwise the extension reads from the unmigrated path, reports "not configured", and the new Vault-encrypted value sits unused. Search your extension for the credential's name (case-insensitive) and replace every read with await ctx.secrets.get("<name>").
V32 publish-time validator flags these legacy paths once enforcement turns on. Until then, audit by hand — and remember it's the read site that betrays you, not the write.
Mental model
┌─────────────────────────────────────────────────────────────────┐
│ User opens the Imperal Panel │
│ Your ext → Settings → Secrets → "api_key ○ Not set" │
└─────────────────────────────┬───────────────────────────────────┘
│ types value, Save
▼
┌─────────────────────────────────────────────────────────────────┐
│ Panel UI — input type="password", state reset on submit │
│ PUT /api/extensions/<your_ext>/secrets/api_key │
└─────────────────────────────┬───────────────────────────────────┘
│ authenticated user session
▼
┌─────────────────────────────────────────────────────────────────┐
│ Platform secrets endpoint │
│ 1. authenticate the caller │
│ 2. verify manifest.secrets[] declares the name │
│ 3. encrypt with the platform KMS (key non-exportable) │
│ 4. persist ciphertext to the encrypted-secrets store │
│ 5. write a federal-retention audit row │
└─────────────────────────────┬───────────────────────────────────┘
│
▼
┌───────────────────────────┐
│ Platform KMS │
│ AES-256-GCM │
│ Key never leaves │
└───────────────────────────┘
Later: user types "search my top tracks" → web-kernel dispatches your handler
┌─────────────────────────────────────────────────────────────────┐
│ Your extension handler │
│ api_key = await ctx.secrets.get("api_key") │
│ SDK → platform secrets endpoint → KMS decrypt → plaintext │
│ Handler uses api_key for one outbound call │
│ Local variable goes out of scope after return │
└─────────────────────────────────────────────────────────────────┘Federal contract — seven invariants
| # | Invariant | What it guarantees |
|---|---|---|
| 1 | I-SECRETS-USER-SCOPED | Cross-user reads return 403 — Denis cannot see Bob's secrets, even if both have the same name in the same ext_id |
| 2 | I-SECRETS-NEVER-LOGGED | Plaintext never lands in action_ledger, journals, error responses, Temporal event history, chat history, or backups. Audit rows store value_length + sha256_prefix8 fingerprint only |
| 3 | I-SECRETS-EXT-SCOPED | Extension A cannot read Extension B's secrets — even for the same user. Tokens carry ext_id claim; URL {ext_id} must match |
| 4 | I-SECRETS-VAULT-DEPENDENCY | KMS endpoint down → 503 fail-closed. No fallback decryption, no plaintext cache anywhere |
| 5 | I-SECRETS-AUDIT-FOREVER | Every secret operation writes retention_class='security_forever' — survives every retention purge cycle (CJIS 7-year minimum) |
| 6 | I-SECRETS-HANDLER-SCOPE-MEMORY | Plaintext lives only inside one handler call stack. SDK has no module/class-level cache. No @lru_cache. Never persists between handler calls |
| 7 | I-SECRETS-CONTRACT-DECLARED | Manifest is the single source of truth. Reading an undeclared name raises SecretNotDeclaredError at runtime; the V32 publish-time validator rejects extensions that bypass @ext.secret for credential-like field access |
Full statements + enforcement sites: Federal contract.
Plaintext lifecycle — three transient places only
Plaintext exists in exactly these three places, each only for the duration of one request or one handler call:
- Platform KMS memory during a single encrypt or decrypt call (KMS's own process)
- Platform secrets endpoint process memory between receiving the KMS response and writing the HTTP response back to the SDK (one local variable, never assigned to instance attribute or module dict)
- SDK process memory inside the handler call where
ctx.secrets.get(name)was invoked (local variable in the handler scope; goes out of scope on return)
Plaintext is never in:
- the database (only ciphertext)
- Redis (no secret caching, period)
- chat history or audit-ledger data / error fields
- service journals
- workflow event history
- platform backups (only ciphertext travels with the encrypted-secrets store)
- HTTP error response bodies (exceptions never embed the value)
If you find any code path that puts plaintext somewhere else, that's a federal violation — file a security incident, don't try to fix it on the spot.
write_mode — who can write the value
Each secret declares a write_mode:
"user"(default) — only Panel UI (authenticated user session) can write. Extension code callingctx.secrets.set()raisesSecretWriteForbidden. Use this for API keys the user pastes from a provider's dashboard."extension"— onlyctx.secrets.set()can write. Panel shows the field as read-only with helper text "the extension will write this after you authorize". Use this for OAuth refresh tokens that the extension acquires after the user goes through an authorize flow."both"— either side can write. Use sparingly; common for OAuth access tokens where the user can manually seed an initial value and the extension later rotates programmatically.
required — metadata flag (dispatch-time gate not yet implemented)
required=True is currently a manifest-level metadata flag. It lands in imperal.json and is read by the Panel Secrets tab UI (which can highlight unset required secrets with a "Configure" prompt), but the web-kernel does not currently block handler dispatch on missing required secrets. A handler whose required secret is unset will be invoked normally, and await ctx.secrets.get(name) returns None.
Handle the None case explicitly in your handler until the web-kernel-side gate ships:
@chat.function
async def search_my_library(ctx, params):
api_key = await ctx.secrets.get("spotify_api_key")
if api_key is None:
return ActionResult.error(
"Spotify isn't connected yet — please add your API key in extension settings.",
retryable=False,
)
# ...use api_key for this handler call only...The future secret_missing_card dispatch gate is in scope for the EXT-SECRETS-V1 spec but not landed; check the changelog before relying on it.
Encryption key — what the platform guarantees
The encryption key is non-exportable — it lives only inside the platform KMS and never appears in plaintext outside it. The platform handles key custody, rotation, disaster recovery, and durable backup; as an extension author, you don't address the KMS directly.
If the KMS endpoint is unavailable for any reason, ctx.secrets.get() raises SecretVaultUnavailable → handlers fail closed. This is by design (I-SECRETS-VAULT-DEPENDENCY): the platform never silently substitutes a fallback decryption path, and it never caches plaintext.
What's still in flight (v4.2.2 → v4.3+)
- V32 publish-time validator — AST scanner that rejects extensions reading
os.environ.get("*KEY*"|"*TOKEN*"|...)orredis.hget("*", "*token*")without a matching@ext.secretdeclaration. Spec'd, local scanner implemented, Dev Portal integration follow-up. - Existing plaintext-stored credentials (BYOLLM keys in Redis, MS Ads OAuth in DB rows) — not auto-migrated. Extension authors migrate at their own pace by adding
@ext.secretdeclarations and switching read sites. - Tier-2 FIDO2/zero-knowledge — passphrase-derived per-user keying material for PCI-grade isolation. Deferred (UX cost too high; demand not validated). Not in v1.
See also
- @ext.secret reference — full decorator signature, kwargs, exceptions
ctx.secretsAPI — get/set/delete/is_set/listui.Passwordprimitive — required write surface (v4.2.6+)- Manifest reference —
secrets[]— JSON schema - Federal contract — all 7 EXT-SECRETS invariants in the table
- Handle user API keys recipe — practical pattern
- Changelog v4.2.2 → v4.2.6 — release notes for the full EXT-SECRETS-V1 sweep