---
title: "Build a Supabase-Backed Voice Agent with RLS and Edge Functions"
description: "Use Supabase Auth, RLS-protected Postgres, and Deno Edge Functions to ship a multi-tenant voice agent. Real working SQL policies and TS code."
canonical: https://callsphere.ai/blog/vw2h-build-supabase-voice-agent-rls-edge-functions
category: "AI Infrastructure"
tags: ["Tutorial", "Build", "Supabase", "RLS", "Edge Functions"]
author: "CallSphere Team"
published: 2026-04-13T00:00:00.000Z
updated: 2026-05-07T09:27:41.034Z
---

# Build a Supabase-Backed Voice Agent with RLS and Edge Functions

> Use Supabase Auth, RLS-protected Postgres, and Deno Edge Functions to ship a multi-tenant voice agent. Real working SQL policies and TS code.

> **TL;DR** — Supabase gives you Postgres + RLS + Auth + Deno Edge Functions in one box. Add OpenAI Realtime via the Edge Function as a WebSocket relay, and you have a multi-tenant voice agent in an afternoon.

## What you'll build

A Supabase project with two tables (`calls`, `messages`), per-tenant RLS, an Edge Function that mints ephemeral OpenAI tokens, and a second Edge Function that relays Realtime traffic. Each call inserts rows that downstream LiveView/React clients see in real time.

## Prerequisites

1. Supabase project (free tier is enough).
2. `supabase` CLI 1.180+.
3. `OPENAI_API_KEY` set as a secret: `supabase secrets set OPENAI_API_KEY=...`.
4. Auth provider configured (we'll use email/magic link).
5. Familiarity with SQL and Postgres RLS.

## Architecture

```mermaid
flowchart LR
  B[Browser w/ Supabase JWT] -- WS --> EF[Edge Fn: voice-relay]
  EF -- WS --> O[OpenAI Realtime]
  EF -- insert --> PG[(Postgres + RLS)]
  PG -- realtime --> B
```

## Step 1 — Schema with RLS

```sql
create table calls (
  id uuid primary key default gen_random_uuid(),
  user_id uuid references auth.users (id) not null,
  started_at timestamptz default now(),
  ended_at timestamptz
);

create table messages (
  id bigserial primary key,
  call_id uuid references calls (id) on delete cascade,
  role text check (role in ('user','assistant','system')),
  text text,
  inserted_at timestamptz default now()
);

alter table calls enable row level security;
alter table messages enable row level security;

create policy "owner reads calls" on calls
  for select using (user_id = auth.uid());

create policy "owner inserts calls" on calls
  for insert with check (user_id = auth.uid());

create policy "owner reads messages" on messages
  for select using (
    exists (select 1 from calls c where c.id = messages.call_id and c.user_id = auth.uid())
  );
```

## Step 2 — Edge Function: mint ephemeral key

`supabase/functions/mint-key/index.ts`:

```typescript
import { createClient } from "jsr:@supabase/supabase-js@2";

Deno.serve(async (req) => {
  const auth = req.headers.get("Authorization") ?? "";
  const sb = createClient(
    Deno.env.get("SUPABASE_URL")!,
    Deno.env.get("SUPABASE_ANON_KEY")!,
    { global: { headers: { Authorization: auth } } }
  );
  const { data: { user } } = await sb.auth.getUser();
  if (!user) return new Response("unauthorized", { status: 401 });

const r = await fetch("[https://api.openai.com/v1/realtime/sessions](https://api.openai.com/v1/realtime/sessions)", {
    method: "POST",
    headers: {
      Authorization: "Bearer " + Deno.env.get("OPENAI_API_KEY"),
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      model: "gpt-4o-realtime-preview-2025-06-03",
      voice: "alloy",
    }),
  });
  return new Response(await r.text(), { headers: { "content-type": "application/json" } });
});
```

Deploy: `supabase functions deploy mint-key`.

## Step 3 — Edge Function: voice relay with persistence

```typescript
import { createClient } from "jsr:@supabase/supabase-js@2";

Deno.serve(async (req) => {
  if (req.headers.get("upgrade") !== "websocket")
    return new Response("ws only", { status: 400 });
  const { socket, response } = Deno.upgradeWebSocket(req);

const sb = createClient(
    Deno.env.get("SUPABASE_URL")!,
    Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!,
  );

let callId: string | null = null;
  const oai = new WebSocket(
    "wss://api.openai.com/v1/realtime?model=gpt-4o-realtime-preview-2025-06-03",
    ["realtime",
     "openai-insecure-api-key." + Deno.env.get("OPENAI_API_KEY"),
     "openai-beta.realtime-v1"]);

oai.onmessage = async (e) => {
    socket.send(e.data);
    const msg = JSON.parse(e.data);
    if (msg.type === "response.text.done" && callId) {
      await sb.from("messages").insert({
        call_id: callId, role: "assistant", text: msg.text,
      });
    }
  };

socket.onmessage = (e) => {
    const m = JSON.parse(e.data);
    if (m.type === "client.call_start") {
      sb.from("calls").insert({ user_id: m.user_id }).select().single()
        .then(({ data }) => { callId = data?.id; });
    } else {
      oai.send(e.data);
    }
  };
  socket.onclose = () => oai.close();
  return response;
});
```

## Step 4 — Realtime subscription in the browser

```typescript
import { createClient } from "@supabase/supabase-js";
const sb = createClient(URL, ANON);

sb.channel("messages")
  .on("postgres_changes",
    { event: "INSERT", schema: "public", table: "messages" },
    (p) => render(p.new))
  .subscribe();
```

Because RLS scopes the subscription to the user's own `call_id` rows, no extra filtering needed.

## Step 5 — Set `auth.uid()` for transactional writes

For multi-statement Edge Function writes that need RLS, you must set the JWT explicitly:

```sql
select set_config('request.jwt.claim.sub', '...uuid...', true);
```

Or use the user-scoped client (auth header forwarded), and Supabase does it for you.

## Common pitfalls

- **Service-role inserts bypass RLS** — don't use it for user-tied data without re-checking.
- **Edge Function 60s walltime cap** — for long calls, return early and let the client reconnect.
- **Forgetting `auth.uid()`** in policies — global table read.
- **WebSocket subprotocol leaks API key** — mint ephemeral and POST it instead.

## How CallSphere does this in production

We don't run on Supabase ourselves (we run our own Postgres on a 72.62 box with 115+ tables across 6 verticals), but we recommend Supabase for our [affiliate](/affiliate) developer-tier customers — under $1k MRR, RLS-protected, deploys in an hour. See [/pricing](/pricing) for the equivalent CallSphere plans.

## FAQ

**Can I run all this without my own server?** Yes — Edge Functions cover both the relay and the token issuance.

**RLS performance?** Negligible — Postgres compiles policies into the query plan.

**What about HIPAA?** Supabase has a BAA on Pro+; CallSphere has it on Pro+ too.

**Can I use pgvector?** Yes — Supabase ships pgvector for RAG.

**Cost at 10k calls/day?** ~$25 Supabase + your OpenAI bill.

## Sources

- [Supabase Edge Functions docs](https://supabase.com/docs/guides/functions)
- [Transactions and RLS in Edge Functions (marmelab)](https://marmelab.com/blog/2025/12/08/supabase-edge-function-transaction-rls.html)
- [Supabase WebSockets](https://supabase.com/docs/guides/functions/websockets)
- [ElevenLabs ↔ Supabase integration](https://elevenlabs.io/agents/integrations/supabase)

---

Source: https://callsphere.ai/blog/vw2h-build-supabase-voice-agent-rls-edge-functions
