ข้ามไปยังเนื้อหา

Edge Functions กับฐานข้อมูล

ในตัว Edge Function คุณ query ฐานข้อมูลด้วย supabase-js ได้เหมือนโค้ดฝั่ง client เป๊ะ แต่ key ที่คุณใช้สร้าง client ตัวนั้นต่างหากที่ตัดสินว่า Row Level Security จะบังคับใช้หรือถูกข้ามไปเลย

ทุกอย่างในบทนี้รันกับ table จริงที่ namespace ไว้ตัวเดียว — table โน้ตส่วนตัวที่แต่ละ row เป็นของ user คนเดียวเท่านั้น

-- supabase/migrations/9001_edge_notes.sql
create 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_notes
for select
to authenticated
using (auth.uid() = user_id);
create policy "edge_notes_own_insert"
on public.edge_notes
for insert
to authenticated
with check (auth.uid() = user_id);

user จริงสองคนสมัครผ่าน GoTrue แล้ว seed โน้ตคนละหนึ่งอันแบบตรง ๆ (RLS ที่มีแค่ policy select/insert-own ปฏิเสธ write ทางอื่นทั้งหมดโดย default การ seed จึงต้อง bypass มันตั้งใจ เหมือน setup script ฝั่ง server ทั่วไป)

alice_id = 49de779e-056a-4d8a-9941-18f8a1136fc9 ("alice private note")
bob_id = 0dd713b3-94ee-4413-8a0c-58e47626b323 ("bob private note")

Edge Function ก็แค่ TypeScript ฝั่งเซิร์ฟเวอร์ ไม่มีอะไรห้ามสร้าง supabase-js client แล้ว query แบบเดียวกับที่ browser ทำ สร้างด้วย anon key และไม่ forward user token จะถูก RLS บังคับเหมือน request ที่ไม่ authenticated และไม่ได้อะไรกลับมาจาก edge_notes เลย

supabase/functions/edge-notes-anon/index.ts
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}

ไม่ใช่ error — เป็น array ว่าง policy select อนุญาตแค่ auth.uid() = user_id และ client ที่ใช้ anon key ไม่มี auth.uid() เลย ทุก row เลยถูกกรองออกแบบเงียบ ๆ ตรงตามที่ RLS ควรทำกับผู้เรียกที่ไม่ได้ authenticated

สร้าง client ตัวเดียวกันด้วย service_role key แทน

supabase/functions/edge-notes-service/index.ts
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}

table เดียวกัน .select('*') ไม่กรองเหมือนเดิม แต่ตอนนี้ ทุก row จากทุก user กลับมาหมด — RLS ถูกข้ามไปทั้งหมด ไม่สนใจ policy เลย นี่เป็น pattern ที่ใช้กันทั่วไปและถูกต้อง เพราะ Edge Function คือโค้ดฝั่งเซิร์ฟเวอร์ที่เชื่อถือได้ ไม่เหมือน browser ที่ user ของตัวเองสามารถ inspect หรือแก้ไขได้ แต่มันมาพร้อมความรับผิดชอบที่เปลี่ยนไปจริง ๆ เมื่อ RLS ออกจากสมการไปแล้ว ฐานข้อมูลไม่ได้ตรวจอะไรแทน client ตัวนี้อีกต่อไป access control ที่จำเป็นตอนนี้ต้องถูกเขียนไว้ในโค้ดของ function เองให้ถูกต้อง

Forward JWT ของผู้เรียก ทำให้ RLS ยังทำงาน แค่ scope ตาม user นั้น

หัวข้อที่มีชื่อว่า “Forward JWT ของผู้เรียก ทำให้ RLS ยังทำงาน แค่ scope ตาม user นั้น”

pattern ที่สาม และพบบ่อยที่สุด สำหรับ function ที่ทำงาน แทนผู้เรียก ที่ล็อกอินอยู่: forward header Authorization ของเขาเอง — token เดียวกับที่ supabase.functions.invoke() แนบให้อัตโนมัติ — เข้าไปใน client ที่ยังสร้างด้วย anon key

supabase/functions/edge-notes-forward/index.ts
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' },
})
})

เรียกด้วย access token จริงของ user สองคนที่ต่างกัน .select('*') แบบไม่กรองตัวเดิมนี้คืนมาแค่หนึ่ง row ต่อคน — row ของตัวเองเท่านั้น

$ 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}

function ไม่ได้สิทธิ์เพิ่มขึ้นเลย มันแค่เพิ่ม logic ฝั่งเซิร์ฟเวอร์ไว้หน้าสิ่งที่ user มีอยู่แล้ว — RLS บังคับใช้เหมือน user แต่ละคน query Postgres ตรง ๆ เอง โดย function ไม่เคยเห็น row ของอีกคนเลย

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
สามวิธีสร้าง Supabase client ใน Edge Function พิสูจน์กับ table เดียวกัน

สมมติคุณต้องการ endpoint ที่คืน aggregate report ที่ policy RLS ของ user ทั่วไปไม่ควรให้เห็นเลย RLS ไม่สามารถ “อนุญาตแค่ aggregate query นี้อันเดียว” ได้ มันจะให้เห็น row หรือไม่ให้เห็นเลยเท่านั้น Edge Function คือที่ที่เหมาะจะบังคับข้อยกเว้นแบบนี้ — ตรวจตัวตนด้วย token ที่ forward มาของผู้เรียกก่อน แล้วค่อยใช้ service_role หลังจากผ่านการตรวจแล้วเท่านั้น

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' },
})
})

function เองคือ trust boundary ตรงนี้ ไม่ใช่ RLS: มันตรวจว่าใครเรียกด้วย token ของ user เอง แล้วค่อยใช้ service_role เพื่อดึงข้อมูลที่ RLS อย่างเดียวไม่มีทางเปิดให้เห็นได้อย่างปลอดภัย

supabase-js ไม่ใช่ทางเดียวที่จะเข้าถึงฐานข้อมูลจาก Edge Function — driver postgres npm ต่อผ่าน Supavisor pooler แบบ transaction mode ได้ตรง ๆ มีประโยชน์เวลาต้องการ raw SQL หรือ feature ของ driver ที่ supabase-js ไม่ได้ห่อไว้ให้ การต่อ pooler ของ local stack นี้ต้องใช้ tenant id จริงของมัน ยืนยันด้วยการ query table tenant ของ Supavisor เองตรง ๆ แทนที่จะเดา

$ docker exec -i supabase_db_probe psql "postgresql://postgres:[email protected]:5432/_supabase" \
-c "select external_id from _supavisor.tenants;"
external_id
-------------
pooler-dev

username ของ connection string คือ postgres.<tenant external_id> ไม่ใช่ postgres.<project_id> แบบที่ตัวอย่างเอกสารของ hosted project บอกไว้ — รายละเอียดจริงที่มีเฉพาะในเครื่องนี้ ที่ควรยืนยันจริงมากกว่าเดา

supabase/functions/edge-notes-pooler/index.ts
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"}

transaction mode ของ Supavisor จะให้ client หนึ่ง connection ฝั่ง backend Postgres แค่ช่วงเวลาของ transaction เดียว แล้วคืนกลับเข้า pool — query ถัดไปของ client อื่นอาจไปเจอ backend คนละตัวเลย driver postgres npm เตรียม query ทุกตัวเป็น named prepared statement โดย default และ official troubleshooting guide บอกผลลัพธ์ชัดเจนว่า “although the direct connections and Supavisor in session mode support prepared statements, Supavisor in transaction mode does not.” prepared statement ที่สร้างไว้กับ backend หนึ่งจะไม่มีอยู่บน backend อื่นที่ query ถัดไปไปเจอเลย วิธีแก้คือ option prepare: false ที่ใช้ไปแล้วข้างบน ส่วนเวอร์ชันที่พังแบบ default เก็บไว้ในบทนี้เพื่อการอ้างอิง (คุ้มที่จะใช้เฉพาะตอนต้องการ reproduce หรือพูดถึง failure mode นี้เท่านั้น)

supabase/functions/edge-notes-pooler-prepared/index.ts
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' },
})
})

function pooler ข้างบนสร้าง client postgres ครั้งเดียวที่ module scope ไม่ได้สร้างในตัว request handler — การใช้ warm isolate ซ้ำแบบเดียวกับที่วัด cold start ในบทที่แล้วใช้ได้กับ connection นี้ด้วย เรียกติดกันสามครั้ง ได้ backend เดิม และช่องว่างเวลา cold-vs-warm ตรงกัน

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"}

backendPid เหมือนกันทั้งสามครั้งคือหลักฐาน: client sql ที่อยู่ module scope และ pooled backend connection ที่ Supavisor จัดสรรให้มัน รอดข้ามการเรียกทั้งสามครั้งบน isolate ที่ warm อยู่ — ถ้าเรียก postgres(...) ใหม่ในตัว handler แทน (ไม่ใช่ที่ module scope) จะทำลายสิ่งนี้ไปเลย ต้อง reconnect ใหม่ทุก request

AI ถูกขอให้เขียน “endpoint ที่คืนโน้ตของ user ที่ล็อกอินอยู่” แล้วได้โค้ดนี้มา

🐛 หา bug ในโค้ดนี้

supabase/functions/get-my-note/index.ts
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' },
})
})
เฉลย

โค้ดนี้ “ทำงานได้” ทุกครั้งที่ทดสอบด้วยมือ ตามที่ตั้งใจไว้เป๊ะ — จนกว่าจะมีใครส่ง userId ที่ต่างออกไปใน request body function นี้ใช้ service_role (ข้าม RLS ไปทั้งหมด ตามที่พิสูจน์ไว้ข้างบน) และเชื่อค่า userId ที่มาจาก request body ของผู้เรียกเอง ตรง ๆ ไม่ใช่อะไรที่ตรวจสอบฝั่งเซิร์ฟเวอร์เลย — เป็น IDOR (Insecure Direct Object Reference) แบบคลาสสิก: ผู้เรียกที่ล็อกอินอยู่คนไหนก็อ่านโน้ตของใครก็ได้ แค่เปลี่ยน field เดียวใน JSON ที่ส่งไป วิธีแก้คือ pattern “forward JWT ของผู้เรียก” จากบทนี้เป๊ะ ๆ: สร้าง client ด้วย header Authorization ที่ forward มาของผู้เรียกเองกับ key anon ตัด parameter userId ออกจาก request body ไปเลย แล้วปล่อยให้ policy RLS auth.uid() = user_id — ที่พิสูจน์แล้วข้างบนว่า scope ผลลัพธ์ตาม user ถูกต้อง — ทำหน้าที่กรองที่ฐานข้อมูลถูกออกแบบมาให้ทำ ถ้าจำเป็นต้องใช้ service_role client จริง ๆ สำหรับอย่างอื่นใน function เดียวกัน ตัวตนของ user ก็ยังต้องมาจาก auth.getUser() กับ token ที่ forward มาของเขาเอง ไม่ใช่จาก field ที่ client ส่งมาเอง

ฐานข้อมูลแยกไม่ออกว่า “Edge Function เป็นคน query” กับ “browser เป็นคน query” — สิ่งที่มันเห็นได้คือ role อะไรและ claim อะไร (โดยเฉพาะ auth.uid()) มากับ request และ RLS policy ถูกประเมินกับสิ่งนั้นตรง ๆ ไม่ว่า layer ไหนของ stack คุณจะเป็นคนยิง query client ที่ใช้ anon key ไม่ forward token เลยไม่มี auth.uid() เลย RLS จึงกรองเหมือนไม่มีใครล็อกอิน token ของ user ที่ forward มามี auth.uid() จริงของ user นั้น RLS จึงกรองเหมือนเป็น query ตรงของ user คนนั้นเป๊ะ service_role ในโมเดลนี้ไม่ใช่ “user ที่มีพลังมากกว่า” — Postgres เองมองว่ามันข้ามการตรวจ row security ไปทั้งหมด นี่คือเหตุผลที่ทั้งสาม key ข้างบนให้ผลลัพธ์ที่ต่างกันโดยสิ้นเชิงกับ .select('*') ตัวเดียวกันที่ไม่กรองอะไรเลย

  • การ forward JWT ของผู้เรียกทำให้ทุก RLS policy ที่คุณเขียนไว้แล้วทำงานต่อในฟังก์ชันได้ฟรี ๆ — แต่ต้องแลกกับการที่ function ถูกจำกัดพอ ๆ กับตัว user เอง ซึ่งถูกต้องเป๊ะสำหรับ “เพิ่ม logic ฝั่งเซิร์ฟเวอร์ไว้หน้าสิ่งที่ user คนนี้ทำได้อยู่แล้ว” และผิดเป๊ะสำหรับอะไรก็ตามที่ต้อง cross user จริง ๆ (admin report, cross-user aggregate)
  • service_role อ่านเขียนอะไรก็ได้ ปลดล็อก pattern ที่ RLS อย่างเดียวทำไม่ได้ — แต่ต้องแลกกับการกลายเป็น access-control boundary แบบ manual เต็มตัว: ไม่มีอะไรนอกจากโค้ดของ function เองที่หยุดการใช้งานผิด ๆ ได้ พิสูจน์ข้างบนด้วย IDOR bug ที่เชื่อ userId จาก client แทนที่จะเชื่อ token ที่ตรวจแล้ว
  • การต่อ postgres ตรง ๆ ผ่าน Supavisor เลี่ยง query builder ของ supabase-js ไปเลย สำหรับ raw SQL หรือ feature ของ driver ที่ไม่รองรับ — แต่ต้องแลกกับความไม่เข้ากันของ prepared statement ใน transaction mode ที่มองไม่เห็นจนกว่าจะปิด named prepared statement ตรง ๆ
  • “ใช้ supabase-js ใน Edge Function ปลอดภัยกว่า browser ใช้โดยธรรมชาติ” client library ไม่เปลี่ยนพฤติกรรมของ RLS เลย สิ่งที่เปลี่ยนพฤติกรรมของ RLS คือ key และ token ที่โค้ดเลือกใช้ พิสูจน์ข้างบนด้วยผลลัพธ์ที่ต่างกันสามแบบจาก query เดียวกัน
  • “service_role ใน function โอเค ตราบใดที่ function รันฝั่งเซิร์ฟเวอร์เท่านั้น” การรันฝั่งเซิร์ฟเวอร์ไม่ได้เพิ่ม authorization ให้เองเลย SpotTheBug ข้างบนคือโค้ด service_role ที่รันอยู่ตรงจุดที่ควรรัน (Edge Function ที่ deploy แล้ว) แล้วก็ยังรั่วโน้ตของทุก user อยู่ดี เพราะไม่มีอะไรตรวจว่าใครเป็นคนถาม
  • “forward JWT ของ user ช้ากว่าใช้ service_role ตรง ๆ” client ที่ forward token ยังผ่าน path PostgREST/Postgres เดียวกัน แค่มี RLS check เพิ่มเข้ามา ไม่ใช่ path ที่ต่างกันอย่างมีนัยสำคัญ — ความต่างของต้นทุนจริงในบทนี้คือ isolate cold กับ warm ไม่ใช่ key ที่ใช้
  • “postgres npm client ทำงานเหมือนกันไม่ว่าจะผ่าน Supavisor transaction mode หรือ connection ตรง” พิสูจน์และอ้างอิงไว้ข้างบน: prepared statement (default ของ driver) เป็นความไม่เข้ากันที่มีเอกสารระบุไว้เฉพาะ transaction mode ไม่ใช่ข้อจำกัดทั่วไปของ Postgres
Edge Function query edge_notes ด้วย client ที่ใช้ anon key และไม่ forward header Authorization เลย จะได้อะไรกลับมา และทำไม
เกิดอะไรกับ RLS เมื่อ Edge Function ใช้ service_role key
ทำไม edge-notes-forward ถึงคืนแค่ row ของ Alice ตอนเรียกด้วย token ของ Alice และคืนแค่ของ Bob ตอนเรียกด้วย token ของ Bob
ทำไมการต่อ pooler ด้วยชื่อ Docker service supabase_pooler_probe ถึงพังจากในฟังก์ชัน แต่ใช้ได้ปกติผ่าน docker exec เข้า container เดียวกัน
ทำไม postgres npm driver ต้องใช้ prepare: false เวลาต่อผ่าน Supavisor transaction mode
function ที่ AI สร้างใช้ service_role client แล้วกรอง edge_notes ด้วย userId ที่อ่านตรงจาก request body ความเสี่ยงจริงคืออะไร และวิธีแก้คือ?