Secrets
@ext.secret — store and use credentials safely: per-user keys and OAuth tokens (scope=user) AND your own shared developer-owned credentials like OAuth client secrets (scope=app), encrypted at rest, never exposed to admins, logs, or backups.
Most extensions eventually need a credential — and there are two kinds, which Imperal keeps strictly apart:
- The user's own credential — a personal API key they paste, or the OAuth refresh token your extension receives after that user logs in. Different for every user. →
scope="user"(the default). - Your own developer credential — the OAuth
client_id/client_secretof your app registered at the provider, or a shared API key you pay for. The same for every user, set once by you. The end user never sees or types it. →scope="app"(since SDK v5.8.0).
EXT-SECRETS-V1 is the federal way to declare both, store them encrypted, and read them in handlers without ever exposing plaintext to admins, logs, or backups — through the same await ctx.secrets.get(name) call. The platform decides where to read from based on the secret's declared scope; your handler code is identical either way.
Which scope do I use?
One question decides it
Is this value the same for every user of my extension, and do I — the developer — own it?
- Yes →
scope="app"— you set it once (in the Developer Portal); every user's handler reads the same value. Nobody but you ever sees it. - No (it belongs to the user, or differs per user) →
scope="user"— the default; each user supplies their own, and one user can never read another's.
| Credential | Whose is it? | scope |
|---|---|---|
| OAuth client_id / client_secret (your registered app at Google/Spotify/GitHub) | yours, shared by all users | app |
| A shared third-party API key you pay for (one key, all your users call through it) | yours, shared | app |
| The user's personal API key they paste from their own provider account | the user's | user |
| OAuth refresh / access token captured after the user authorizes | each user's | user |
| Webhook signing secret the user pastes from their provider dashboard | the user's | user |
The canonical example — "Sign in with Google / Spotify"
An OAuth login uses both scopes at once — this is exactly where authors get confused, so make the split explicit:
| Value | Whose is it? | Declare as | Who sets it |
|---|---|---|---|
client_id, client_secret (your app's identity at the provider) | yours — one pair, all users | scope="app" | you, once, in the Developer Portal Secrets tab |
refresh_token, access_token (the result of this user authorizing) | each user's | scope="user", write_mode="extension" | your extension writes it per user after the OAuth callback |
So you hold one pair of app credentials; every user logs in against them and gets back their own token. That is why a user clicking "Connect Spotify" just works: they never need your client secret, and you never need theirs. (Before scope="app", the only place to put the client secret was a per-user slot — so it worked for whoever the developer was testing as, and failed with "no keys" for everyone else. scope="app" is the fix.)
When to use this — and when not to
Use @ext.secret for:
- The OAuth
client_id/client_secretand shared API keys you own that power every user's experience →scope="app" - A personal credential each user supplies and your extension calls a third-party API with — Spotify API key, OpenAI key, GitHub PAT →
scope="user" - OAuth refresh / access tokens your extension acquires after a user authorize flow →
scope="user",write_mode="extension" - Webhook signing secrets the user pastes from a provider dashboard (Stripe
whsec_*, GitHubsha256=...) →scope="user" - Any value where "an admin reading it would be a federal violation"
Don't use it for:
- 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.
A note on "platform" keys. Earlier guidance said shared keys belong in env vars / systemd overrides. That is no longer the way: a credential your extension owns and shares across users is exactly
scope="app"— declared in your manifest, encrypted in Vault, set in the Developer Portal. The env var path survives only as a temporary migration bridge (env_fallback).
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 guarantees
| # | Guarantee | What it guarantees |
|---|---|---|
| 1 | User-scoped | Cross-user reads are denied — Denis cannot see Bob's secrets, even if both have the same name in the same ext_id |
| 2 | Never logged | Plaintext never lands in the activity log, journals, error responses, durable background-task history, chat history, or backups. Audit rows store a length + a short fingerprint of the value only |
| 3 | Extension-scoped | Extension A cannot read Extension B's secrets — even for the same user. Access is bound to the requesting extension's identity, which must match the secret's owner |
| 4 | Fails closed | If the encryption service is unavailable, reads fail closed. No fallback decryption, no plaintext cache anywhere |
| 5 | Audited forever | Every secret operation is recorded in a security-retention class that survives every retention purge cycle (CJIS 7-year minimum) |
| 6 | Handler-scope memory only | Plaintext lives only inside one handler call. The SDK keeps no module- or class-level cache and never persists the value between handler calls |
| 7 | Declared in the manifest | The manifest is the single source of truth. Reading an undeclared name raises SecretNotDeclaredError at runtime; the publish-time validator rejects extensions that bypass @ext.secret for credential-like field access |
Full statements: Federal contract.
These seven describe scope="user" secrets. scope="app" deliberately relaxes #1 (it is shared, not user-scoped) and in exchange tightens who may touch it: writes are owner-only (Developer Portal), plaintext reads are kernel-only (never an end user, never the LLM). Guarantees #2–#6 (never logged, extension-scoped, fail-closed, audited forever, handler-scope memory) hold for both scopes. See App-scope secrets.
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
write_mode governs scope="user" secrets only — it decides who may write that user's value. (For scope="app" secrets the write rule is fixed and stronger — only the app owner, via the Developer Portal; see App-scope secrets. write_mode is ignored there.)
Each scope="user" 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.
App-scope secrets — developer-owned, shared
A scope="app" secret (SDK v5.8.0+) is held once for the whole extension instead of once per user. It's how you ship a credential you own — your OAuth client secret, a shared API key — so that every user benefits without ever seeing it.
# Declared exactly like any secret, with scope="app":
ext.secret(
name="google_client_secret",
description="Google OAuth client secret — your app's, shared by all users.",
scope="app",
)(lambda: None)How it behaves — the three things that make it "shared":
- One stored value, read for everyone. It is stored under your
ext_id(not under any user), and your handler reads it with the sameawait ctx.secrets.get("google_client_secret")— for every user, transparently. You write the handler once; it works for all. - Only you (the owner) can write it. You set and rotate the value in the Developer Portal → your app → Secrets tab. Neither end users nor extension code can write an app-scope secret:
ctx.secrets.set("google_client_secret", …)raisesSecretWriteForbidden, and the platform rejects any non-owner write with403. (Platform/system apps are owned by an admin, who sets their app-scope keys the same way.) - End users never see it. An app-scope secret is invisible to users: it does not appear in their Panel Secrets list, a user session cannot read its value (
403), and — like every secret — its plaintext never reaches the LLM, logs, or backups. Only your handler (running server-side, on behalf of whichever user) and you (a masked set / not-set status in the Dev Portal) ever interact with it.
In short: scope="user" is the user's; scope="app" is yours. Same ctx.secrets.get call, two storage homes, two access rules — chosen by the one scope field in your manifest.
Migrating an existing shared key out of env
If your extension currently reads a shared key from an env var (the old pattern), declare it scope="app" and add an env_fallback so nothing breaks during the cutover:
ext.secret(
name="google_client_secret",
description="Google OAuth client secret — your app's, shared by all users.",
scope="app",
env_fallback="IMPERAL_APPSECRET_MAIL_GOOGLE_CLIENT_SECRET",
)(lambda: None)On a read, the platform tries Vault first; if it's empty, it falls back to that env var, so the extension keeps working while you migrate. Once you save the value in the Dev Portal Secrets tab it comes from Vault and the env var is ignored — then the env line can be removed.
env_fallback is namespaced for safety
env_fallback MUST name a var inside your app's own IMPERAL_APPSECRET_<EXT>_<NAME> namespace. SecretSpec rejects anything else at build time, and the platform ignores an out-of-namespace name at runtime — an extension can never point a fallback at another app's secret or a platform secret like STRIPE_SECRET_KEY.
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) -> ActionResult:
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...
return ActionResult.ok()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: 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
System Apps
System apps — first-party Imperal extensions that auto-install for every user, stay hidden from the marketplace, and can never be uninstalled by anyone.
Long-running operations
Long-running operations — background tasks and automatic chat delivery for AI calls and slow work that runs beyond the chat timeout, then lands back in chat.