Skip to main content

Vault Architecture

This page is a technical reference for security-conscious operators and contributors who want to understand how Switchbord’s vault integration works end to end. For setup instructions, see Vault Configuration.

Storage Model

Switchbord uses two layers of database objects to store workspace secrets: vault.secrets (Supabase built-in) The actual encrypted secret values live here. Each row contains a secret column encrypted with pgsodium (AES-256-GCM), a name column for human-readable identification, and a key_id referencing the pgsodium key used for encryption. Rows are identified by a UUID. app_private.workspace_secret_bindings (Switchbord custom) This table maps a workspace to its secrets:
The binding table holds no sensitive data itself. It is purely a pointer: for workspace X and secret kind Y, look up vault secret Z. The actual decryption happens in vault.secrets.

Why app_private Schema

The app_private schema is used for tables and functions that must not be exposed via PostgREST (Supabase’s auto-generated REST API). By default, PostgREST only exposes objects in the public schema (and schemas explicitly listed in db_schema). Placing workspace_secret_bindings in app_private ensures that:
  • No anonymous or authenticated client can directly SELECT from the bindings table via the Supabase client library
  • No REST endpoint for /app_private/workspace_secret_bindings is generated
  • Access is only possible through explicitly defined functions that run as SECURITY DEFINER
Row-level security is also enabled on workspace_secret_bindings as a defense-in-depth measure, but the app_private schema placement is the primary isolation layer.
The app_private schema pattern is a standard Supabase convention for sensitive schema objects. It is documented in Supabase’s own “Row Level Security” and “Functions” guides as the recommended way to protect data that should never be directly accessible.

Read Path Architecture

The read path is designed to avoid requiring a direct database connection from the worker service. All sensitive reads go through Supabase RPC functions defined with SECURITY DEFINER.

SECURITY DEFINER Functions

To ensure vault items are never exposed via the standard public schema or auto-generated REST endpoints, Switchbord uses the app_private schema pattern. app_private.read_workspace_secret Joins app_private.workspace_secret_bindings with vault.decrypted_secrets to return the plaintext value. It runs as SECURITY DEFINER, allowing it to access the vault schema which is normally restricted. app_private.resolve_workspace_by_verify_token Used during Meta webhook verification to route requests to the correct workspace without exposing tokens to the client.

Public RPC Wrappers

Because PostgREST only exposes the public schema, we provide thin wrappers that enforce authorization:
  • public.sb_read_workspace_secret: Validates auth.role() == 'service_role' before delegating to the private function.
  • public.sb_resolve_workspace_by_verify_token: Follows the same security pattern for token resolution.
These wrappers explicitly reject calls made with the anon key or a user-scoped JWT. Only the service role key (SUPABASE_SECRET_KEY) can invoke them. This is enforced at the SQL level via auth.role() checks inside the wrapper functions.

Write Path

The write path flows through the Switchbord web application and does not involve the worker:
The write path uses a direct Postgres connection (SUPABASE_DB_URL) when calling vault.create_secret() from outside the application server, but the Next.js API route handles this using the Supabase service role client. Self-hosters do not need SUPABASE_DB_URL in the worker — only in the web application if vault writes are needed from server-side code.

Encryption at Rest

Supabase Vault uses pgsodium, a PostgreSQL extension wrapping libsodium.
  • Algorithm: XChaCha20-Poly1305 (a stream cipher with authentication)
  • Key management: pgsodium uses a root key derived from a secret stored outside the database (in Supabase’s key management infrastructure for Cloud, or configured separately for self-hosted)
  • Encryption scope: Each secret in vault.secrets is encrypted with a key derived from the root key. The encrypted ciphertext and key ID are stored together; the plaintext is never persisted.
  • Decryption: The vault.decrypted_secrets view performs decryption transparently using pgsodium.crypto_aead_det_decrypt(). Only database roles with SELECT permission on this view can read plaintext values.
For Supabase Cloud deployments, the pgsodium root key is managed by Supabase and is never exposed to project owners. For self-hosted Supabase, you are responsible for securing the root key — see the Supabase self-hosting guide for key configuration.

Access Control Summary

The worker authenticates all RPC calls using the service role JWT (SUPABASE_SECRET_KEY). This key must be kept secret and must never be exposed to browser clients or logged.

Migration Reference

The vault integration is composed of two layers: Supabase built-in (no action required)
  • vault schema and vault.secrets table
  • vault.decrypted_secrets view
  • vault.create_secret() function
  • pgsodium extension
These are present on all Supabase Cloud projects and can be enabled on self-hosted Supabase via CREATE EXTENSION supabase_vault. Switchbord custom (applied via migrations)
  • app_private schema creation
  • app_private.workspace_secret_bindings table
  • app_private.read_workspace_secret() function
  • app_private.resolve_workspace_by_verify_token() function
  • public.sb_read_workspace_secret() wrapper
  • public.sb_resolve_workspace_by_verify_token() wrapper
These are created by Switchbord’s database migrations in supabase/migrations/. Running supabase db push applies all pending migrations and ensures these objects exist. If you need to inspect the function definitions directly:

Security Considerations for Self-Hosters

Service role key exposure The SUPABASE_SECRET_KEY can call any RPC function and bypasses RLS. Treat it with the same care as a database root password. Rotate it immediately if you suspect exposure. Self-hosted Supabase pgsodium key If running Supabase locally or on your own infrastructure, the pgsodium root key must be set before any secrets are stored. If the key changes or is lost, all stored vault secrets become unrecoverable — there is no key escrow. Migration integrity The SECURITY DEFINER functions in app_private are only as secure as the database owner role. Ensure that only trusted parties have the ability to redefine or replace these functions via migrations.