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. A previous Phase-2 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"}