Edge Functions and the Database
The idea in one sentence
Section titled “The idea in one sentence”Inside an Edge Function you can query the database with supabase-js exactly like client-side code, but which key you initialize that client with decides whether Row Level Security applies or is bypassed entirely.
A real table with RLS to prove it against
Section titled “A real table with RLS to prove it against”Everything in this lesson runs against one real, namespaced table — a private notes table where each row belongs to exactly one user:
-- supabase/migrations/9001_edge_notes.sqlcreate table public.edge_notes ( id uuid primary key default gen_random_uuid(), user_id uuid not null, body text not null);
alter table public.edge_notes enable row level security;
create policy "edge_notes_own_rows"on public.edge_notesfor selectto authenticatedusing (auth.uid() = user_id);
create policy "edge_notes_own_insert"on public.edge_notesfor insertto authenticatedwith check (auth.uid() = user_id);Two real users signed up through GoTrue, and one note each seeded directly (RLS with only select/insert-own policies denies every other write path by default, so seeding bypasses it deliberately, the same way any server-side setup script would):
alice_id = 49de779e-056a-4d8a-9941-18f8a1136fc9 ("alice private note")bob_id = 0dd713b3-94ee-4413-8a0c-58e47626b323 ("bob private note")Same client, two very different keys
Section titled “Same client, two very different keys”An Edge Function is just server-side TypeScript, so nothing stops it from creating a supabase-js client and running queries the same way a browser would. Initialized with the anon key and no forwarded user token, it is subject to RLS as an unauthenticated request — and gets nothing back from edge_notes:
import { createClient } from 'jsr:@supabase/supabase-js@2'
Deno.serve(async (_req) => { const client = createClient( Deno.env.get('SUPABASE_URL')!, Deno.env.get('SUPABASE_ANON_KEY')!, ) const { data, error } = await client.from('edge_notes').select('*') return new Response(JSON.stringify({ data, error }), { headers: { 'Content-Type': 'application/json' }, })})$ curl "$FUNCTIONS_URL/edge-notes-anon" -H "Authorization: Bearer $ANON_KEY" -H "apikey: $ANON_KEY"{"data":[],"error":null}Not an error — an empty array. The select policy only allows auth.uid() = user_id, and an anon-key client has no auth.uid() at all, so every row is filtered out silently, exactly as RLS is supposed to behave for an unauthenticated caller.
The service_role key bypasses RLS entirely
Section titled “The service_role key bypasses RLS entirely”Initialize the same client with the service_role key instead:
import { createClient } from 'jsr:@supabase/supabase-js@2'
Deno.serve(async (_req) => { const client = createClient( Deno.env.get('SUPABASE_URL')!, Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!, ) const { data, error } = await client.from('edge_notes').select('*') return new Response(JSON.stringify({ data, error }), { headers: { 'Content-Type': 'application/json' }, })})$ curl "$FUNCTIONS_URL/edge-notes-service" -H "Authorization: Bearer $ANON_KEY" -H "apikey: $ANON_KEY"{"data":[ {"id":"8313942c-...","user_id":"49de779e-...","body":"alice private note"}, {"id":"9dd850a2-...","user_id":"0dd713b3-...","body":"bob private note"}],"error":null}Same table, same unfiltered .select('*'), and now every row from every user comes back — RLS is bypassed entirely, regardless of policies. This is a legitimate, common pattern, because an Edge Function is trusted server-side code, unlike a browser that could be inspected or tampered with by its own user. But it comes with a real shift in responsibility: once RLS is out of the picture, the database is no longer checking anything on this client’s behalf. Whatever access control is required now has to be written, correctly, in the function’s own code.
Forwarding the caller’s JWT keeps RLS working, scoped to that user
Section titled “Forwarding the caller’s JWT keeps RLS working, scoped to that user”The third, and most common, pattern for a function acting on behalf of the signed-in caller: forward their own Authorization header — the same token supabase.functions.invoke() attached automatically — into a client still built with the anon key:
import { createClient } from 'jsr:@supabase/supabase-js@2'
Deno.serve(async (req) => { const authHeader = req.headers.get('Authorization') ?? '' const client = createClient( Deno.env.get('SUPABASE_URL')!, Deno.env.get('SUPABASE_ANON_KEY')!, { global: { headers: { Authorization: authHeader } } }, ) const { data, error } = await client.from('edge_notes').select('*') return new Response(JSON.stringify({ data, error }), { headers: { 'Content-Type': 'application/json' }, })})Called with two different real users’ access tokens, the same unfiltered .select('*') now returns exactly one row each — their own:
$ curl "$FUNCTIONS_URL/edge-notes-forward" -H "Authorization: Bearer $ALICE_TOKEN" -H "apikey: $ANON_KEY"{"data":[{"id":"8313942c-...","user_id":"49de779e-...","body":"alice private note"}],"error":null}
$ curl "$FUNCTIONS_URL/edge-notes-forward" -H "Authorization: Bearer $BOB_TOKEN" -H "apikey: $ANON_KEY"{"data":[{"id":"9dd850a2-...","user_id":"0dd713b3-...","body":"bob private note"}],"error":null}The function has not gained any extra access; it is simply adding server-side logic in front of the access the user already had — RLS applies exactly as if each user had queried Postgres directly, with the function never seeing the other user’s row at all.
flowchart LR
req["Incoming request"] --> edge["Edge Function"]
edge -->|"anon key, no token"| anonClient["anon key client"]
edge -->|"forwarded user token"| userClient["anon key + forwarded Authorization"]
edge -->|"service_role key"| adminClient["service_role client"]
anonClient -->|"RLS filters everything out"| db[("edge_notes")]
userClient -->|"RLS: caller's own rows only"| db
adminClient -->|"RLS bypassed entirely"| db A concrete example: an admin-only report
Section titled “A concrete example: an admin-only report”Suppose you need an endpoint that returns an aggregate report that no ordinary user’s RLS policies should ever allow them to see. RLS cannot selectively “allow this one aggregate query”; it either lets a user see rows or it does not. An Edge Function is the natural place to enforce that exception — check identity with the caller’s own forwarded token first, then reach for service_role only after that check passes:
Deno.serve(async (req) => { const authHeader = req.headers.get('Authorization')!
// Check the caller's identity and role using their own forwarded token const supabase = createClient( Deno.env.get('SUPABASE_URL')!, Deno.env.get('SUPABASE_ANON_KEY')!, { global: { headers: { Authorization: authHeader } } }, ) const { data: { user } } = await supabase.auth.getUser()
const { data: profile } = await supabase .from('profiles') .select('is_admin') .eq('id', user?.id) .single()
if (!profile?.is_admin) { return new Response('Forbidden', { status: 403 }) }
// Only after that check passes, query freely with service_role const supabaseAdmin = createClient( Deno.env.get('SUPABASE_URL')!, Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!, ) const { data: report } = await supabaseAdmin.rpc('total_revenue_report')
return new Response(JSON.stringify(report), { headers: { 'Content-Type': 'application/json' }, })})The function itself is the trust boundary here, not RLS: it checks who is calling with the user’s own token, and only then reaches for service_role to fetch data that RLS alone could never expose safely.
Direct Postgres via Supavisor, transaction mode
Section titled “Direct Postgres via Supavisor, transaction mode”supabase-js is not the only way to reach the database from inside a function — the postgres npm driver can connect straight through the Supavisor pooler in transaction mode, useful when you need raw SQL or a driver supabase-js does not wrap. Connecting to this local stack’s pooler needs its real tenant id, confirmed by querying Supavisor’s own tenant table directly rather than guessing:
$ docker exec -i supabase_db_probe psql "postgresql://postgres:[email protected]:5432/_supabase" \ -c "select external_id from _supavisor.tenants;" external_id------------- pooler-devThe connection string username is postgres.<tenant external_id>, not postgres.<project_id> as the hosted-project docs example suggests — a real, local-only detail worth confirming rather than assuming:
import postgres from 'npm:postgres@3'
// Module-level client: created once per isolate, reused across warm invocations.const sql = postgres(Deno.env.get('PG_POOLER_URL')!, { prepare: false })
Deno.serve(async (_req) => { const rows = await sql`select pg_backend_pid() as backend_pid, count(*) as note_count from public.edge_notes` return new Response(JSON.stringify({ backendPid: rows[0].backend_pid, noteCount: rows[0].note_count }), { headers: { 'Content-Type': 'application/json' }, })})$ echo 'PG_POOLER_URL=postgresql://postgres.pooler-dev:[email protected]:55329/postgres' >> .env.local$ curl "$FUNCTIONS_URL/edge-notes-pooler" -H "Authorization: Bearer $ANON_KEY" -H "apikey: $ANON_KEY"{"backendPid":3916,"noteCount":"2"}The prepared-statement caveat in transaction mode
Section titled “The prepared-statement caveat in transaction mode”Supavisor’s transaction mode assigns a client a backend Postgres connection only for the duration of one transaction, then returns it to the pool — a different client’s next query can land on a completely different backend. The postgres npm driver prepares every query as a named statement by default, and the official troubleshooting guide is explicit about the consequence: “although the direct connections and Supavisor in session mode support prepared statements, Supavisor in transaction mode does not.” A prepared statement created against one backend simply does not exist on whichever different backend the next query happens to land on. The fix is the prepare: false option already used above; the broken default, kept here for the lesson (only worth reaching for when you specifically need to reproduce or discuss this failure mode):
import postgres from 'npm:postgres@3'
// prepare defaults to true — this is the broken version, kept for the lesson's proof.const sql = postgres(Deno.env.get('PG_POOLER_URL')!)
Deno.serve(async (_req) => { const rows = await sql`select count(*) as note_count from public.edge_notes where user_id = ${crypto.randomUUID()}` const rows2 = await sql`select count(*) as note_count from public.edge_notes where user_id = ${crypto.randomUUID()}` return new Response(JSON.stringify({ ok: true, rows, rows2 }), { headers: { 'Content-Type': 'application/json' }, })})Connection reuse across invocations
Section titled “Connection reuse across invocations”The pooler function above created its postgres client once, at module scope, not inside the request handler — the same warm-isolate reuse from the previous lesson’s cold-start measurement applies to this connection too. Three consecutive calls, same backend, and a cold-vs-warm timing gap that matches:
first call (cold isolate, new pg connection): time_total=0.093268s {"backendPid":3916,"noteCount":"2"}second call (warm, reused pg connection): time_total=0.009264s {"backendPid":3916,"noteCount":"2"}third call: time_total=0.009755s {"backendPid":3916,"noteCount":"2"}The identical backendPid across all three calls is the proof: the module-level sql client, and the pooled backend connection Supavisor assigned it, both survived between invocations on the warm isolate — a fresh postgres(...) call inside the handler (rather than at module scope) would defeat this entirely, reconnecting on every single request.
Reading AI-generated code
Section titled “Reading AI-generated code”An AI assistant asked for “an endpoint that returns the signed-in user’s own note” produced this:
🐛 Spot the bug
import { createClient } from 'jsr:@supabase/supabase-js@2'
Deno.serve(async (req) => {const { userId } = await req.json()
const supabase = createClient( Deno.env.get('SUPABASE_URL')!, Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!,)
const { data, error } = await supabase .from('edge_notes') .select('*') .eq('user_id', userId)
return new Response(JSON.stringify({ data, error }), { headers: { 'Content-Type': 'application/json' },})})Show the answer
This “works” in every manual test the way it is meant to — until someone passes a different userId in the request body. The function uses service_role (bypassing RLS entirely, as proved above) and trusts a userId value that came straight from the caller’s own request body, not from anything verified server-side — a classic IDOR (Insecure Direct Object Reference): any signed-in caller can read anyone’s notes just by changing one field in the JSON they send. The fix is exactly the “forward the caller’s JWT” pattern from this lesson: build the client with the caller’s own forwarded Authorization header and the anon key, drop the userId parameter from the request body entirely, and let RLS’s auth.uid() = user_id policy — already proved above to correctly scope results per user — do the filtering the database was designed to do. If a service_role client is genuinely needed for something else in the same function, the user’s identity must still come from auth.getUser() against their own forwarded token, never from a client-supplied field.
Mental Model
Section titled “Mental Model”The database cannot tell the difference between “an Edge Function queried this” and “a browser queried this” — what it can see is which role and which claims (auth.uid(), in particular) arrived with the request, and RLS policies are evaluated against exactly that, regardless of which layer of your stack issued the query. An anon-key client with no forwarded token carries no auth.uid() at all, so RLS filters as if no one is signed in. A forwarded user token carries that user’s real auth.uid(), so RLS filters exactly as it would for that user’s own direct query. service_role is not “a more powerful user” in this model — Postgres itself treats it as bypassing row security checks entirely, which is why the three keys above produce three completely different result sets against the identical unfiltered .select('*').
Trade-offs
Section titled “Trade-offs”- Forwarding the caller’s JWT keeps every RLS policy you have already written working, for free, inside a function — at the cost of the function being just as restricted as the user is, which is exactly right for “add server logic in front of what this user could already do,” and exactly wrong for anything that must legitimately cross users (an admin report, a cross-user aggregate).
service_rolecan read and write anything, unlocking patterns RLS alone cannot express — at the cost of becoming a fully manual access-control boundary: nothing except the function’s own code stops it from being misused, proved above by the IDOR bug that trusted a client-supplieduserIdinstead of a verified token.- A direct
postgresconnection through Supavisor avoidssupabase-js’s query builder entirely, for raw SQL or an unsupported driver feature — at the cost of transaction-mode’s prepared-statement incompatibility, invisible until named prepared statements are turned off explicitly.
Common Misconceptions
Section titled “Common Misconceptions”- “Using
supabase-jsinside an Edge Function is inherently safer than a browser using it.” The client library does not change RLS’s behavior at all — what changes RLS’s behavior is which key and which token the code decides to use, proved above with three different outcomes from the same query. - “
service_roleinside a function is fine as long as the function only runs server-side.” Running server-side does not add any authorization by itself — the SpotTheBug above isservice_rolecode, running exactly where it was supposed to (a deployed Edge Function), and still leaking every user’s notes because nothing checked who was asking. - “Forwarding the user’s JWT is slower than using service_role directly.” The forwarded-token client still goes through the same PostgREST/Postgres path with an extra RLS check, not a meaningfully different one — the real cost difference in this lesson is cold vs. warm isolate reuse, not which key was used.
- “A
postgresnpm client behaves the same over Supavisor transaction mode as over a direct connection.” Proved and cited above: prepared statements (the driver’s default) are a documented incompatibility specific to transaction mode, not a general Postgres limitation.