> ## Documentation Index
> Fetch the complete documentation index at: https://docs.switchbord.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Vault Architecture

> A deep-dive reference on how Supabase Vault is integrated into Switchbord — covering the storage model, read and write paths, encryption, access control, and the SECURITY DEFINER RPC layer.

# 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](/operations/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:

```
workspace_secret_bindings
  id              uuid PRIMARY KEY
  workspace_id    uuid NOT NULL  -- references workspaces(id)
  secret_kind     text NOT NULL  -- 'meta-access-token', 'openai-api-key', etc.
  vault_secret_id uuid NOT NULL  -- references vault.secrets(id)
  created_at      timestamptz
  updated_at      timestamptz
  UNIQUE (workspace_id, secret_kind)
```

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.

<Info>
  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.
</Info>

***

## 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`.

```
Worker service
    |
    | supabase.rpc('sb_read_workspace_secret', { p_workspace_id, p_kind })
    |
Supabase PostgREST (REST API layer)
    |
    | calls public wrapper function
    |
public.sb_read_workspace_secret(p_workspace_id uuid, p_kind text)
    |   [SECURITY INVOKER, checks caller is service_role]
    |
    | calls internal function
    |
app_private.read_workspace_secret(p_workspace_id uuid, p_kind text)
    |   [SECURITY DEFINER, runs as owning role]
    |
    | SELECT decrypted_secret FROM vault.decrypted_secrets
    | JOIN app_private.workspace_secret_bindings ...
```

### `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.

<Warning>
  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.
</Warning>

***

## Write Path

The write path flows through the Switchbord web application and does not involve the worker:

```
Settings UI (browser)
    |
    | POST /api/settings/secrets  { kind, value }
    |   with user session JWT
    v
Next.js API route
    |
    | validates session, resolves workspace_id
    | calls storeWorkspaceSecret(workspaceId, kind, value)
    v
storeWorkspaceSecret()  (server-side utility)
    |
    | Step 1: supabase.rpc('vault_create_secret', { value, name })
    |   returns vault_secret_id
    |
    | Step 2: INSERT INTO app_private.workspace_secret_bindings
    |   (workspace_id, secret_kind, vault_secret_id)
    |   ON CONFLICT DO UPDATE  (upsert — replaces existing binding)
    v
Secret stored and bound to workspace
```

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](https://github.com/michelp/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.

<Info>
  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](https://supabase.com/docs/guides/self-hosting) for key configuration.
</Info>

***

## Access Control Summary

| Layer                           | Mechanism                            | Effect                                                    |
| ------------------------------- | ------------------------------------ | --------------------------------------------------------- |
| `app_private` schema            | Not exposed by PostgREST             | Tables and internal functions cannot be accessed via REST |
| `workspace_secret_bindings` RLS | Row-level security enabled           | Defense-in-depth: bindings scoped to workspace            |
| `public.sb_*` wrappers          | `auth.role() = 'service_role'` check | Only service role JWT can invoke vault reads              |
| `vault.decrypted_secrets`       | Privileged role required             | Direct access blocked for anon and authenticated roles    |
| `app_private.*` functions       | `SECURITY DEFINER`                   | Functions run as owner, not caller — controlled elevation |

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:

```sql theme={null}
-- Check the public wrapper exists
\df sb_read_workspace_secret

-- View the function source
SELECT prosrc
FROM pg_proc
WHERE proname = 'sb_read_workspace_secret';
```

***

## 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.

***

## Related Guides

* [Vault Configuration](/operations/vault-configuration) — self-hosting setup, env vars, and troubleshooting
* [Secrets and Encryption](/security/secrets-and-encryption) — security policy, API key hashing, and HTTP security headers
* [Multi-Tenancy](/security/multi-tenancy) — workspace isolation model
* [Authentication](/security/authentication) — JWT handling and session model
