Workspace & Team

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 typeReference columnHelper functions
Provider API keysprovider_keys.vault_secret_idinsert_provider_key, get_decrypted_provider_key, delete_provider_key
Integration OAuthintegrations.vault_secret_idinsert_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:

  1. The browser POSTs the plaintext to the insert_provider_key RPC.
  2. The RPC, running as security_definer, calls vault.create_secret(...) which returns a secret UUID.
  3. The RPC inserts a row in provider_keys with the UUID in vault_secret_id (no plaintext).
  4. The UI gets back the new row (with the UUID, not the plaintext).

When a chat request needs the key:

  1. The chat-proxy Edge Function (also security_definer) calls get_decrypted_provider_key(provider_key_id).
  2. The function reads the row, looks up vault.secrets by UUID, and returns the plaintext only to the function caller (the proxy).
  3. 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:

  1. Add the new key (you get a new provider_keys row).
  2. Verify it's working with a test chat.
  3. 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:

  1. Revoke it at the provider (OpenAI, Anthropic, etc.) — that's the only place truly authoritative.
  2. Delete it from Project88 so we stop trying to use it.
  3. 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

On this page