Skip to content

Secrets

ctx.secrets.get(KEY) reads from a per-(tenant, app) encrypted store. Two tiers, with tenant-override beating dev-default.

┌─────────────────────────────┐
│ ctx.secrets.get('OPENAI_KEY') │
└──────────────┬──────────────────┘
Look up tenant-override (this tenant + this app)
┌───────────┴────────────┐
│ │
found? not found
│ │
▼ ▼
return decrypted Look up dev-default (this app)
┌───────────┴────────────┐
│ │
found? not found
│ │
▼ ▼
return decrypted return null
  • Dev-default: you (the dev) seed it once with linkworld secrets set. Visible to every tenant install of the app.
  • Tenant-override: the tenant admin sets it via their app-settings UI. Wins over dev-default for that one tenant.

Both are encrypted at rest with AES-256-GCM, stored in linkworld_app_secrets, and never displayed after creation.

PatternTierExample
You pay for the API, all tenants shareDev-defaultOPENAI_KEY for an LLM the app uses internally
Each tenant brings their own credentialForce tenant-override (no default)STRIPE_SECRET_KEY for tenant’s own Stripe account
You ship a sane default, tenant can overrideDev-default + advertise override is supportedMAX_CONCURRENT_REQUESTS=10

CLI:

Terminal window
linkworld secrets set OPENAI_KEY=sk-real-... \
--app tax-bot \
--description "LLM key — bills to dev's account"

Dev console:

  1. Open /dev/apps/<your-app> → Secrets tab → “Add secret”
  2. Key (must match ^[A-Z][A-Z0-9_]{0,63}$), value (write-only field), optional description (max 200 chars, no control chars)
  3. Submit. The value is gone from the UI immediately — there’s no way to read it back.

Tenant admins set these in their own UI (different surface, different auth). You don’t touch them — that’s the point of the tier separation.

If you want to require a tenant-override (no fallback), don’t seed a dev-default. ctx.secrets.get(...) returns null and your code should fail loudly:

key = await ctx.secrets.get("STRIPE_SECRET_KEY")
if key is None:
raise RuntimeError(
"This tenant has not configured STRIPE_SECRET_KEY. "
"Ask the admin to set it in the app's settings."
)
val = await ctx.secrets.get("MY_KEY")
OutcomeReturns
Tenant-override existsdecrypted string
Only dev-default existsdecrypted string
Neither setNone
Network error reaching the platformNone (fail-closed)
4xx from the platform (auth error)None
Malformed key (doesn’t match the regex)None

The SDK never falls back to environment variables. An earlier design did, and the security review (rightly) flagged it: a partner process can mutate its own env, so an env-fallback let partner code plant secret values that won on any platform hiccup. Today it’s fail-closed.

Same CLI command, same UI button — just submit a new value. The old value is overwritten in the same row; no “rotate window” or active/ deprecated split. If you need that pattern, store two distinct keys (MY_KEY_PRIMARY, MY_KEY_BACKUP) and rotate them in sequence.

Every read is audit-logged:

  • Visible to the dev in the Logs / Errors tabs (filtered to your app)
  • Tenant identifier truncated to 8-char prefix (devs don’t need full tenant UUIDs)
  • The value itself NEVER appears in any audit trail or log line — only the key name + outcome

The audit row is written before the value is returned. If the audit bus is unreachable the platform still serves the read, but bumps a Prometheus counter (linkworld_app_secret_audit_failures_total) so an alarm fires — silent gaps are not acceptable.

# 1. Key not configured (most common).
api_key = await ctx.secrets.get("OPENAI_KEY")
if api_key is None:
return {"error": "tenant has not configured OPENAI_KEY"}
# 2. Key configured but the underlying API rejects it (rotated, revoked).
try:
response = await openai_client.complete(api_key=api_key, ...)
except openai_client.AuthError:
# Don't retry blindly — tell the tenant to update their key.
return {"error": "OPENAI_KEY rejected by upstream; please rotate"}