Vault and secrets
How Project88 stores every credential — provider keys, integration tokens, webhook secrets — in Supabase Vault.
Every credential in Project88 — LLM provider API keys, OAuth tokens for Gmail/Calendar/Slack/etc., webhook secrets — is encrypted in Supabase Vault. Plaintext is never persisted in normal tables and never reaches the browser.
Why Vault
Supabase Vault is a Postgres extension that encrypts secrets at rest with a key derived from your Supabase project's secret key. Decrypting a secret requires service-role access and goes through dedicated security-definer functions.
What's stored in Vault
| Credential type | Reference column | Helper functions |
|---|---|---|
| Provider API keys | provider_keys.vault_secret_id | insert_provider_key, get_decrypted_provider_key, delete_provider_key |
| Integration OAuth | integrations.vault_secret_id | insert_integration, get_integration_token, delete_integration |
| Webhook secrets (planned) | (TBD) | (TBD) |
The provider_keys table no longer has a plaintext api_key column —
that was dropped in migration 014 when Vault was introduced. The
integrations table never had one; tokens land straight in Vault via
insert_integration (migration 016 fixed the return-type bug there).
How a key flows
When you add a provider key in the UI:
- The browser POSTs the plaintext to the
insert_provider_keyRPC. - The RPC, running as
security_definer, callsvault.create_secret(...)which returns a secret UUID. - The RPC inserts a row in
provider_keyswith the UUID invault_secret_id(no plaintext). - The UI gets back the new row (with the UUID, not the plaintext).
When a chat request needs the key:
- The
chat-proxyEdge Function (alsosecurity_definer) callsget_decrypted_provider_key(provider_key_id). - The function reads the row, looks up
vault.secretsby UUID, and returns the plaintext only to the function caller (the proxy). - The proxy uses it to call the upstream provider and discards it after the request.
The browser never sees the plaintext at any step.
Function security
All Vault helpers are security_definer with set search_path to
prevent search-path injection (migration 006 enforced this across all
trigger functions). They check the caller's org_id matches the target
row before returning anything.
Rotating keys
To rotate a key:
- Add the new key (you get a new
provider_keysrow). - Verify it's working with a test chat.
- Deactivate or delete the old key.
Deletion via delete_provider_key removes both the Vault secret and the
row in one transaction.
What to do if a key leaks
If you suspect a key was exposed:
- Revoke it at the provider (OpenAI, Anthropic, etc.) — that's the only place truly authoritative.
- Delete it from Project88 so we stop trying to use it.
- Add a new key.
Project88 never logs key plaintext, but defense-in-depth — revoke at the source.
Column-level encryption (sensitive columns)
Vault is for application secrets (provider keys, OAuth tokens). For
end-user data inside data tables, Project88 has a separate
column-level encryption layer (foundation laid in the app-crypto work).
How it works:
- Any value-storing column (text, number, email, phone, date, etc.) on a
user table can be marked sensitive in
EditColumnSheet. - The column's schema gets
sensitive: true. - Writes go through database triggers that encrypt the value at rest.
- Reads default to a masked placeholder.
- A dedicated reveal RPC decrypts a single value when explicitly requested — every reveal is written to an audit log.
Three column types can't be marked sensitive — they don't store a value directly:
- Formula — derived from other columns at read time
- Child-link — a foreign-key relationship
- Link — a foreign-key relationship
This complements Vault rather than replacing it: Vault for credentials that should never reach the browser, column encryption for end-user data that needs to be readable on demand but with auditability.
Where to next
- Provider API keys
- Organizations
- Webhooks
- Data tables — column types and the Values settings tab