Imperal Docs
Core Concepts

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_secret of 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.
CredentialWhose is it?scope
OAuth client_id / client_secret (your registered app at Google/Spotify/GitHub)yours, shared by all usersapp
A shared third-party API key you pay for (one key, all your users call through it)yours, sharedapp
The user's personal API key they paste from their own provider accountthe user'suser
OAuth refresh / access token captured after the user authorizeseach user'suser
Webhook signing secret the user pastes from their provider dashboardthe user'suser

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:

ValueWhose is it?Declare asWho sets it
client_id, client_secret (your app's identity at the provider)yours — one pair, all usersscope="app"you, once, in the Developer Portal Secrets tab
refresh_token, access_token (the result of this user authorizing)each user'sscope="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_secret and 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_*, GitHub sha256=...) → 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.config or ctx.store
  • Multi-step PII data that needs encryption at rest — imperal_secrets storage 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.

PathWhat lives thereVisible toEncrypted at restFederal-grade
ctx.configNon-sensitive settings — default timezone, feature flags, display preferencesUser, Panel UI, admin tooling (plaintext in user store)NoNo
ctx.secretsCredentials — API keys, OAuth tokens, webhook signing secretsOnly the handler call stack (briefly) and the platform KMSYes (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

#GuaranteeWhat it guarantees
1User-scopedCross-user reads are denied — Denis cannot see Bob's secrets, even if both have the same name in the same ext_id
2Never loggedPlaintext 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
3Extension-scopedExtension 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
4Fails closedIf the encryption service is unavailable, reads fail closed. No fallback decryption, no plaintext cache anywhere
5Audited foreverEvery secret operation is recorded in a security-retention class that survives every retention purge cycle (CJIS 7-year minimum)
6Handler-scope memory onlyPlaintext lives only inside one handler call. The SDK keeps no module- or class-level cache and never persists the value between handler calls
7Declared in the manifestThe 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:

  1. Platform KMS memory during a single encrypt or decrypt call (KMS's own process)
  2. 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)
  3. 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 calling ctx.secrets.set() raises SecretWriteForbidden. Use this for API keys the user pastes from a provider's dashboard.
  • "extension" — only ctx.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":

  1. One stored value, read for everyone. It is stored under your ext_id (not under any user), and your handler reads it with the same await ctx.secrets.get("google_client_secret") — for every user, transparently. You write the handler once; it works for all.
  2. 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", …) raises SecretWriteForbidden, and the platform rejects any non-owner write with 403. (Platform/system apps are owned by an admin, who sets their app-scope keys the same way.)
  3. 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*"|...) or redis.hget("*", "*token*") without a matching @ext.secret declaration. 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.secret declarations 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

On this page