App secrets — the Vault-backed, dashboard-controllable secret store
App-managed credentials (Stripe sk_…/whsec_…, future PATs/tokens) live encrypted
at rest in native Supabase Vault (supabase_vault 0.3.1 — AEAD via libsodium; the
root key lives outside SQL in Supabase's backend and survives backups/replication
encrypted), reachable only through audited SECURITY DEFINER RPCs. This replaces parking
a secret in the cleartext settings_*.data.env store (where the "••••" mask is UI-only)
and replaces CLI supabase secrets set for anything the dashboard needs to rotate.
Deno.env (supabase secrets set) stays the home for TRUE bootstrap secrets only —
SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, SUPABASE_ANON_KEY — the chicken-and-egg
values needed to construct the client itself. Everything else lives in Vault.
Architecture
dashboard Secrets form / MCP create_app_secret
│ (value, write-only)
▼
public.upsert_app_secret() ── SECURITY DEFINER, is_admin() gate
│ │
▼ ▼
vault.create_secret() public.app_secrets (metadata mirror)
(encrypted ciphertext) name · vault_secret_id · secret_class
→ vault.secrets · hint · note · *_by · *_at ← NO value column
│
▼ AFTER trigger
realtime broadcast on `app_secrets:admin`
(metadata columns ONLY)
Edge runtime (service-role)
│ getAppSecret(client, "STRIPE_SB_SECRET_KEY")
▼
public.resolve_app_secret() ── THE decrypt boundary (service_role | admin only)
│
▼
vault.decrypted_secrets → cleartext value (per-cold-start cache; never logged)Components
| Object | Kind | Notes |
|---|---|---|
public.app_secrets | table | Metadata mirror. NO value column. RLS has_capability('global','edit'); direct DML revoked from anon+authenticated (writes go through the RPCs). |
public.upsert_app_secret(name, value, hint, note) | DEFINER RPC | Create or rotate. is_admin() gate, search_path=''. Value passed THROUGH to vault.create_secret/update_secret, never read/returned/logged. Returns metadata only. |
public.delete_app_secret(name) | DEFINER RPC | Drops the ciphertext (vault.secrets) + the metadata row. is_admin() gate. |
public.list_app_secrets() | DEFINER RPC | Metadata only (name/class/hint/note/timestamps). NEVER reads vault.decrypted_secrets. is_admin() gate. |
public.resolve_app_secret(name) | DEFINER RPC | The one decrypt boundary. Returns the cleartext value to the service-role Edge client OR a human admin only; everyone else → insufficient_privilege. Bumps last_accessed_at. |
app_secrets:admin | Realtime topic | Broadcasts metadata-mirror changes; read RLS gated to has_capability('global','view'). Metadata columns ONLY (the mirror has no value), so a value can never ride the firehose. |
_shared/vaultClient.ts getAppSecret(client, name) | Edge helper | Resolves by name via resolve_app_secret; per-cold-start in-memory cache; never logs the value. |
_shared/envSecure.ts getBootstrapEnv(name) | Edge helper | Reads ONLY the bootstrap allowlist (SUPABASE_*) from Deno.env; rejects any other name (steers app secrets to getAppSecret). |
Why values never leak
- No read-back tool on any surface.
list_app_secretsis metadata-only; there is deliberately noget_app_secret. The only path to a cleartext value is the service-role Edge resolver, never an MCP/dashboard call. - The metadata mirror has no value column, so a PostgREST reach + even a service-role
leak on
public.app_secretsyields only names + hints, never ciphertext-with-key. - The
vaultschema is not exposed by PostgREST. Anon/authenticated have NO grant onvault.secrets/vault.decrypted_secrets. - The Realtime broadcast carries the metadata mirror row — which has no value — so the
app_secrets:adminfirehose can't ship a value (the ws: whole-row footgun is avoided by broadcasting from a value-free table). - Blast radius vs the old cleartext env: previously a single
global:viewgrant on a custom role exposed every cleartext env value. Now secrets are AEAD-encrypted at rest / in backups / in replication; the only plaintext exposure is a service-role-key compromise (already game-over) or the audited resolver.
Five-surface parity
- Dashboard — a "Secrets (encrypted)" tab on the Environment page; the value field is write-only (no reveal toggle), name immutable after create, Delete confirm-gated. (Built by the dashboard specialist — see the queue unit #3.)
- MCP —
list_app_secrets(global:view),create_app_secret/update_app_secret/delete_app_secret(global:edit+ adminOnly + confirm; the confirm prompt shows NAME + hint, never the value). - REST — the 4 RPC endpoints (
/rest/v1/rpc/{upsert,delete,list}_app_secret); theresolveRPC is service-role/admin only. (REST manifest entry — dashboard specialist.) - WS — the private
app_secrets:adminchannel (metadata only,global:view). (WS channels doc — dashboard specialist.) - BusyBro —
list_app_secretsis exposed (read-only); the 3 mutators are denylisted (REGISTRY_DENYLIST4th class) — BusyBro is list-only for secrets, since the value always comes from the user, never the brain.
Adding / rotating a secret
- Dashboard → Environment → Secrets (encrypted) → Add Secret → type the name
(e.g.
STRIPE_SB_SECRET_KEY) and paste the value into the write-only field → save. The value is encrypted server-side viaupsert_app_secret→vault.create_secretand is never seen by the assistant or echoed back. - Rotate by editing the secret and entering a new value (re-encrypts via
vault.update_secret). Propagation: Edge readers cache per cold start, so a rotation takes effect as cold starts cycle — redeploy the consuming function for instant. - After confirming the Vault copy resolves, remove any legacy cleartext copy of the same key from the Environment Variables tab.
Consuming a secret in an Edge Function
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.45.0";
import { getBootstrapEnv } from "../_shared/envSecure.ts";
import { getAppSecret } from "../_shared/vaultClient.ts";
const client = createClient(
getBootstrapEnv("SUPABASE_URL"),
getBootstrapEnv("SUPABASE_SERVICE_ROLE_KEY"),
);
const sk = await getAppSecret(client, "STRIPE_SB_SECRET_KEY");
const whsec = await getAppSecret(client, "STRIPE_WEBHOOK_SECRET");
// const stripe = new Stripe(sk); … verify the webhook with whsecBoth are pure NAME references in code — the assistant never sees a value. No
supabase secrets set STRIPE_* step exists; those names live in Vault, typed by the user
into the dashboard Secrets form.
VPS secret matrix
The Vault store is not just for Edge Functions — it is the home for the secrets the VPS
services (dashboard, proxy-server) read at runtime too. The standing rule: on the VPS the only
secrets that may live in systemd env / on-disk are (a) the bootstrap pair + (b) genuinely
machine-local, non-rotatable material. Every OTHER runtime secret is a Vault secret resolved via
getAppSecret(serviceClient, NAME) (Vault-first → env/disk fallback → fail-closed). The
proxy-server reuses its getControlSupabase() service-role client; the dashboard server uses its
own server-only service-role client (web/dashboard/app/lib/supabase/service.ts, guarded by
import "server-only" so it can never reach the browser bundle). The Node readers
(web/dashboard/app/lib/appSecret.ts + web/proxy-server/src/appSecret.ts) are thin mirrors of
the Edge _shared/vaultClient.ts getAppSecret — two copies, like blockRules.ts / types.ts,
since there is no monorepo workspace.
| Secret / config on the VPS | Where it lives | In Vault? | Why |
|---|---|---|---|
SUPABASE_URL + SUPABASE_SERVICE_ROLE_KEY | systemd drop-ins (busymate-{dashboard,dashboard-v2,proxy}.service.d/env.conf) | No — bootstrap | Chicken-and-egg: you cannot resolve a Vault secret without first having a service-role client. Mirrors the Edge BOOTSTRAP_ALLOWLIST in _shared/envSecure.ts. The dashboard's service.ts reads these (NEVER NEXT_PUBLIC_*). |
MYPRIVATEPROXY_FETCH_URL | dashboard: Vault (Secrets tab) → env fallback | Yes (migrating) | The External-Proxy pool fetch URL embeds an API key. /api/proxies resolves it Vault-first via getAppSecret, falls back to the legacy process.env copy, else fail-closes 503. Once Vault-verified, the systemd-env line is removed. |
PROXY_CERT_BUNDLE_AUTH | proxy-server: Vault → env fallback | Yes (migrating) | The cert-bundle endpoint auth. Resolved via the proxy-server's ported getAppSecret over getControlSupabase(). (proxy-server specialist's half of the Wave-2 migration.) |
BUSYMATE_CA_CERT_PEM_B64 | dashboard systemd drop-in | No — PUBLIC config | This is the base64 of the PUBLISHED CA cert that /ca.pem + /ca.mobileconfig serve. It is not a secret — it's the public cert iOS Safari fetches during onboarding. Keeping it in env (vs Vault, or fetching it live from the proxy) means the critical CA-install onboarding endpoint does not depend on Vault/proxy uptime — a deliberate availability choice. |
CA private key ca.key | /home/ubuntu/.busymate-proxy/ca.key (on-disk) | No — machine-local | The proxy mints per-host leaves from it at boot, before any DB/Vault call is possible. Non-rotatable, boot-critical machine-local material. |
| certbot / Let's Encrypt privkeys | /etc/letsencrypt/.../privkey.pem | No — machine-local | nginx TLS material owned by certbot; read at boot. |
profile-signing key sign-key.pem | on-disk | No — machine-local | Code/profile-signing material, machine-local. |
NEXT_PUBLIC_* (NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY) | .env.production (baked at build) | No — public | Public build-time config baked into the client bundle by design; never secrets. |
busymate-status.service (PORT / NODE_ENV) | systemd unit | n/a — no secrets | The standalone status board (:3940) carries only PORT + NODE_ENV (like busymate-marketing). No secret to migrate. |
The dead DASHBOARD_USER / DASHBOARD_PASSWORD env vars (the old HTTP Basic-auth gate, now
Supabase Auth in web/dashboard/proxy.ts — there is no middleware.ts) had zero live
consumer and were deleted from both dashboard units as dead config (not migrated). The
per-secret state tracker is notes/secrets-migration.md.
Consuming a secret on the VPS (dashboard / proxy-server)
// dashboard (Node) — a Route Handler / Server Action:
import { getServiceSupabase } from "@/app/lib/supabase/service"; // server-only
import { getAppSecret } from "@/app/lib/appSecret";
let url: string | null = null;
try {
url = await getAppSecret(getServiceSupabase(), "MYPRIVATEPROXY_FETCH_URL"); // Vault
} catch {
url = process.env.MYPRIVATEPROXY_FETCH_URL ?? null; // legacy env fallback
}
if (!url) return new Response("secret unset", { status: 503 }); // fail-closed
// proxy-server (Node) is identical but reuses getControlSupabase() + src/appSecret.ts.The resolved value is a live credential — never log it, never put it in an error body.
Source
Migration supabase/migrations/20260617020000_app_secrets_vault.sql; Edge helpers
supabase/functions/_shared/vaultClient.ts + envSecure.ts; the VPS Node readers
web/dashboard/app/lib/{appSecret.ts,supabase/service.ts} +
web/proxy-server/src/appSecret.ts; MCP tools in
supabase/functions/_shared/mcpRegistry.ts; BusyBro list-only gate in
supabase/functions/_shared/busybroDispatch.ts. Migration tracker:
notes/secrets-migration.md.