Securing the Data API
ไอเดียในหนึ่งประโยค
หัวข้อที่มีชื่อว่า “ไอเดียในหนึ่งประโยค”table หรือ function ต้องผ่าน สองด่านแยกกัน ก่อนที่ request จะทั้งปลอดภัยและสำเร็จ Data API exposure ตัดสินว่าเข้าถึงผ่าน API ได้เลยไหม ส่วน Row Level Security ตัดสินว่า request ที่เข้าถึงได้แล้วเห็นหรือแก้ row ไหนได้บ้าง
ด่านที่ 1 — Data API exposure
หัวข้อที่มีชื่อว่า “ด่านที่ 1 — Data API exposure”tutorial เก่า ๆ ของ Supabase หลายอันเล่าแค่เรื่อง RLS เหมือนว่า RLS คือทั้งหมด แต่ไม่ใช่ ก่อนที่ RLS จะถูกประเมินด้วยซ้ำ Postgres ต้อง grant สิทธิ์ให้ role ที่ request เข้ามา — anon, authenticated หรือ service_role — ให้แตะต้อง table หรือ function นั้นได้ก่อน
local CLI stack ที่คุณใช้มาตั้งแต่ module Foundations ไม่เดินตาม rollout นี้ พิสูจน์ รันวันนี้ (2026-09-29) กับ local stack ของคอร์สนี้เอง โดยไม่ตั้งค่าอะไรเพิ่มเติมนอกจาก supabase start เปล่า ๆ:
-- supabase/migrations/4001_agapi_demo_table.sqlcreate table public.agapi_demo ( id uuid primary key default gen_random_uuid(), val text);
insert into public.agapi_demo (val) values ('hello-from-postgres');ไม่มี grant ไม่มี dashboard ไม่มีสวิตช์ “Default privileges” — query ในนาม anon ได้ทันที:
curl "$API_URL/rest/v1/agapi_demo?select=*" \ -H "apikey: $ANON_KEY" \ -H "Authorization: Bearer $ANON_KEY"HTTP/1.1 200 OK[{"id":"c00eb34c-...","val":"hello-from-postgres"}]\dp public.agapi_demo ยืนยันเหตุผล — anon กับ authenticated มีสิทธิ์เต็มตั้งแต่วินาทีที่ table ถูกสร้าง:
Access privileges Schema | Name | Type | Access privileges | Column privileges | Policies--------+------------+-------+---------------------------------+-------------------+---------- public | agapi_demo | table | postgres=arwdDxtm/postgres +| | | | | anon=arwdDxtm/postgres +| | | | | authenticated=arwdDxtm/postgres+| | | | | service_role=arwdDxtm/postgres | |(1 row)เหตุผลอยู่ใน supabase/config.toml ที่ CLI scaffold ให้เอง มี comment คอมเมนต์ทิ้งไว้แต่อธิบายครบ:
# Controls whether new tables, views, sequences and functions created in the `public` schema by# `postgres` are reachable through the Data API roles (`anon`, `authenticated`, `service_role`)# without explicit GRANTs, matching the cloud default. Set to `false` to require explicit GRANTs# instead. Left unset, a fresh project falls back to `true`.# auto_expose_new_tables = trueเปิด comment บรรทัดนั้นเป็น auto_expose_new_tables = false ใน supabase/config.toml ของ โปรเจกต์คุณเอง จะทำให้ local development ตรงกับโปรเจกต์ hosted ใหม่หลัง 30 พฤษภาคม 2026: table ใหม่จะไม่ถูก auto-grant อีกต่อไป คุณต้อง grant เองแบบเดียวกับ SQL ด้านล่าง (บทนี้ไม่ได้เปิด setting นี้บน local stack ที่ใช้ร่วมกันของคอร์ส — บทอื่นและผู้อ่านคนอื่นยังพึ่ง default ปัจจุบันอยู่ — แต่มันคือบรรทัดเดียวที่คุณเพิ่มในโปรเจกต์จริงเมื่ออยากให้ local ตรงกับ default ใหม่ของ hosted)
เบื้องหลัง ไม่ว่า exposure จะเกิดจากทางไหน — auto-grant, dashboard, หรือเขียน SQL เอง — มันคือคำสั่ง grant ธรรมดาของ Postgres เสมอ:
-- Table grants: what each role is allowed to attemptgrant select on public.your_table to anon;grant select, insert, update, delete on public.your_table to authenticated;grant all on public.your_table to service_role;
-- Function grants: who may call this RPC endpointgrant execute on function public.your_function to authenticated, service_role;ถ้า role ไหนไม่มี grant บน table นั้นเลย table นั้นจะมองไม่เห็นสำหรับ role นั้นผ่าน Data API — request จะล้มเหลวก่อนที่ RLS จะถูกเช็คด้วยซ้ำ ไม่ว่า RLS policy ของคุณจะหลวมแค่ไหนก็ตาม นี่คือด่านระดับ table/function ที่ตอบคำถามว่า “role นี้แตะ object นี้ได้เลยไหม” ไม่ใช่ “เห็น row ไหนบ้าง” พิสูจน์ได้ด้วยการ revoke grant ของ anon บน table ด้านบน แล้วยิง request แบบเดิมซ้ำ:
-- supabase/migrations/4002_agapi_revoke_anon.sqlrevoke select on public.agapi_demo from anon;HTTP/1.1 401 Unauthorized{"code":"42501","details":null,"hint":"Grant the required privileges to the current role with: GRANT SELECT ON public.agapi_demo TO anon;","message":"permission denied for table agapi_demo"}42501 คือ SQLSTATE insufficient_privilege ของ Postgres เอง — PostgREST ส่งต่อมาตรง ๆ และยังบอกคำสั่ง grant ที่แก้ปัญหาได้ใน hint ด้วยซ้ำ ไม่มีอะไรเกี่ยวกับ RLS ทำงานเลย — table นี้ไม่มี policy อยู่เลย service_role ยังอ่าน row เดิมได้หลัง revoke (ไม่เคยถูกแตะ) เพราะ grant ของ service_role แยกต่างหาก
Exposed schemas — [api] schemas และความหมายจริงของ “not exposed”
หัวข้อที่มีชื่อว่า “Exposed schemas — [api] schemas และความหมายจริงของ “not exposed””supabase/config.toml ของทุกโปรเจกต์ยังระบุด้วยว่า PostgREST มองหา schema ไหนบ้าง แยกต่างหากจาก grant ระดับ table:
[api]schemas = ["public", "graphql_public"]table หนึ่ง ๆ อาจมี grant ถูกต้องทุกอย่างแต่ยังเข้าถึงไม่ได้เลยถ้า schema ของมันไม่อยู่ในลิสต์นั้น — พิสูจน์ด้วยการสร้าง table ที่ grant ครบถ้วน ใน schema ที่ไม่เคยถูกเพิ่มเข้า schemas:
-- supabase/migrations/4003_agapi_priv_schema.sqlcreate schema agapi_priv;
create table agapi_priv.secret_stuff ( id int primary key, val text);
grant usage on schema agapi_priv to anon, authenticated;grant select on agapi_priv.secret_stuff to anon;curl "$API_URL/rest/v1/secret_stuff?select=*" \ -H "apikey: $ANON_KEY" \ -H "Authorization: Bearer $ANON_KEY"HTTP/1.1 404 Not Found{"code":"PGRST205","details":null,"hint":null,"message":"Could not find the table 'public.secret_stuff' in the schema cache"}usage บน schema, select บน table — grant ทั้งสองถูกต้องหมด — แต่ PostgREST ยังคืน 404 PGRST205 ไม่ใช่ 401/403 มันไม่ได้บอกว่า “forbidden” มันบอกว่า table ไม่มีอยู่จริง เพราะ PostgREST introspect เฉพาะ schema ที่อยู่ใน [api] schemas เท่านั้น และ agapi_priv ไม่เคยอยู่ในนั้น (สังเกตว่า error ยังระบุชื่อ public.secret_stuff ด้วย เพราะ default ไปค้นใน schema public ที่มันค้นได้จริง เนื่องจากไม่มี header Accept-Profile ที่ชื่อ agapi_priv มาด้วย) นี่คือ error แบบเดียวกันสำหรับความผิดพลาดสองแบบที่ต่างกันสิ้นเชิง — table ที่ไม่มีอยู่จริง กับ table จริงที่ Postgres ยอมให้แตะได้นอก Data API — ซึ่งตรงกับกับดักใน section Reading AI-generated code ด้านล่างเป๊ะ
ด่านที่ 2 — Row Level Security
หัวข้อที่มีชื่อว่า “ด่านที่ 2 — Row Level Security”เมื่อ table ผ่านด่าน exposure แล้ว Row Level Security (RLS) จะเข้ามาทำงานต่อและตอบคำถามที่แคบลง สำหรับ request ที่เข้ามาในนาม anon, authenticated หรือ user ที่ authenticated ตัวใดตัวหนึ่ง request นั้นเห็นหรือแก้ row ไหนได้บ้าง RLS policy เป็นหัวข้อของ module ถัดไปทั้งหมด ดังนั้นสิ่งที่ต้องจำจากบทนี้คือแค่เส้นแบ่งระหว่างสองชั้นนี้
- Data API exposure — ระดับ table/function object นี้เข้าถึงผ่าน API ได้เลยไหม
- Row Level Security — ระดับ row ในเมื่อเข้าถึงได้แล้ว request นี้เห็น row ไหนบ้าง
ทั้งสองต้อง config ให้ถูกทั้งคู่ มี exposure แต่ไม่มี RLS หมายความว่า table ที่เข้าถึงได้ทางเทคนิคจะคืนทุก row ให้ใครก็ตามที่ได้สิทธิ์เข้าถึง มี RLS แต่ไม่มี exposure หมายความว่า policy ที่ scope ไว้ถูกต้องแล้วจะไม่มี request ไหนไปถึงได้เลย เพราะ table ไม่เคยถูก expose ตั้งแต่แรก
-- Layer 2 lives on the table once it clears layer 1alter table public.your_table enable row level security;
create policy "users read their own rows"on public.your_tablefor selectto authenticatedusing (auth.uid() = user_id);service_role ข้าม RLS ไปเลยทั้งหมด
หัวข้อที่มีชื่อว่า “service_role ข้าม RLS ไปเลยทั้งหมด”Postgres จะข้าม row level security ให้ role ใดก็ตามที่มี attribute BYPASSRLS และ service_role มี attribute นี้ request ที่ authenticate ด้วย service_role key ยังต้องผ่านด่าน 1 อยู่ (ต้องมี grant รองรับ แม้ปกติ service_role จะได้สิทธิ์กว้างอยู่แล้ว) แต่พอผ่านแล้ว RLS policy จะไม่ถูกใช้กับ service_role เลย role นี้เห็นและแก้ได้ทุก row นี่คือเหตุผลที่ service_role key ต้องไม่หลุดไปอยู่ใน browser หรือ mobile client เด็ดขาด สอดคล้องกับคำเตือนจาก module Foundations key นี้มีไว้สำหรับโค้ดฝั่ง server ที่เชื่อถือได้เท่านั้น ที่คุณต้อง enforce กฎการเข้าถึงเองแทนที่จะพึ่ง RLS
flowchart TB
req["Incoming request as anon, authenticated, or service_role"] --> gate1{"Layer 1: Data API exposure — is the table/function reachable at all?"}
gate1 -- "no grant" --> reject1["Rejected: not reachable"]
gate1 -- "granted" --> role{"Role has BYPASSRLS?"}
role -- "yes (service_role)" --> allrows["All rows visible, RLS skipped entirely"]
role -- "no" --> gate2{"Layer 2: Row Level Security — which rows?"}
gate2 -- "no matching policy" --> reject2["Rejected: no rows returned/modified"]
gate2 -- "policy matches" --> scoped["Only matching rows visible/modifiable"] Reading AI-generated code
หัวข้อที่มีชื่อว่า “Reading AI-generated code”AI assistant ที่ถูกขอให้ “ล็อก private settings table ไว้ให้ API แตะไม่ได้” สร้าง migration นี้ขึ้นมา:
🐛 หา bug ในโค้ดนี้
create schema app_private;
create table app_private.feature_flags (key text primary key,enabled boolean not null default false);
grant usage on schema app_private to service_role;grant select on app_private.feature_flags to service_role;
-- "service_role only, so it's safe to read from an Edge Function"เฉลย
migration นี้ internally consistent และ grant ก็ถูกต้องจริง ๆ — service_role อ่าน table นี้ได้จริง และ anon/authenticated อ่านไม่ได้จริง เพราะไม่เคยถูก grant อะไรเลย บั๊กอยู่ที่สิ่งที่ผู้เขียน เชื่อ ว่าตัวเองทำสำเร็จ — เขาเขียน comment อ้างว่า table นี้เข้าถึงได้ “จาก Edge Function” ผ่าน service_role ทาง Data API แล้ว คือผ่าน supabase.from('feature_flags') ด้วย service-role client แต่มันจะไม่มีวันเข้าถึงได้ ด้วยเหตุผลที่ไม่เกี่ยวกับ grant เลย: app_private ไม่อยู่ใน [api] schemas (ที่ default เป็น ["public", "graphql_public"]) ดังนั้น PostgREST ไม่มีวัน introspect schema นี้เลย ไม่ว่าจะเป็น request จาก anon, authenticated หรือ service_role ก็ได้ error เดียวกันคือ 404 PGRST205 "Could not find the table" พิสูจน์แล้วด้านบนสำหรับความผิดพลาดรูปแบบนี้เป๊ะ ทางแก้ไม่ใช่การ grant เพิ่ม แต่คือการตระหนักว่า Data API client ไม่ใช่เครื่องมือที่ถูกต้องตั้งแต่แรกสำหรับกรณีนี้ — จะเพิ่ม app_private เข้า schemas (ซึ่งลดทอนความ “private” ในชื่อไปพอสมควร เพราะทุก role ของ Data API query ได้แล้ว แค่ถูกกั้นด้วย grant) แล้วส่ง Accept-Profile: app_private ก็ได้ หรือ — ทางแก้จริงสำหรับ “server-only settings” — อ่านผ่าน connection Postgres ตรง ๆ (เช่นจาก Edge Function ด้วย postgres.js/pg ผ่าน pooler) แล้วไม่ต้องผ่าน PostgREST เลยก็ได้
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”ลองนึกภาพทุก request เป็นการเดินผ่านอาคารที่มีประตูอิสระสองชั้น ไม่ใช่ชั้นเดียว: ประตูหน้า (Data API exposure — grant ของ Postgres บน schema ที่บอก PostgREST ให้มองหา) และ ประตูห้องแต่ละห้อง (RLS ที่ประเมินทีละ row เมื่อเข้ามาข้างในแล้ว) ช่างกุญแจที่ใส่กุญแจแค่ประตูห้องแต่ปล่อยประตูหน้าไม่ล็อก ไม่ได้ป้องกันอะไรที่ attacker สนใจเลย กลับกัน การก่ออิฐปิดประตูหน้าของห้องที่ไม่เคยมีกุญแจข้างในก็แค่ทำให้ traffic ที่ถูกต้องเข้าไม่ได้เหมือนกัน \dp <table> ตอบคำถามว่า “ประตูหน้าปลดล็อกให้ role นี้ไหม” [api] schemas ตอบคำถามว่า “อาคารนี้อยู่บนแผนที่ที่ PostgREST ถืออยู่ไหม” RLS policy คือสิ่งเดียวที่มองเข้าไปข้างในเมื่อทั้งสองอย่างตอบว่าใช่แล้วเท่านั้น
Trade-offs
หัวข้อที่มีชื่อว่า “Trade-offs”- Auto-expose (default ปัจจุบันของ local, default เก่าของ hosted) prototype ได้เร็ว — table ใหม่ทุกตัวใช้งานได้จาก client ทันที — แลกกับ security default ที่เงียบ: table ที่มีคอลัมน์อ่อนไหวเข้าถึงได้ตั้งแต่วินาทีที่
create tableรัน ก่อนที่คุณจะเขียน RLS policy แม้แต่ตัวเดียว ถ้าลืมล็อกมันไว้ auto_expose_new_tables = false/ default ใหม่ของ hosted ปลอดภัยกว่าโดยโครงสร้าง — ไม่มีอะไรเข้าถึงได้จนกว่าคุณจะสั่ง — แลกกับขั้นตอนที่ต้องทำทุก table: ลืมgrantแล้ว feature ที่ถูกต้องจะ 404 เงียบ ๆ ในแบบที่เข้าใจผิดง่ายว่า “table ไม่มีอยู่” ทั้งที่จริง “table มีอยู่แต่ไม่ถูก expose”- การไม่เอา table เข้า
[api] schemasเลย คือรูปแบบที่แข็งแกร่งที่สุดของ “เข้าถึงผ่าน PostgREST ไม่ได้” แข็งกว่าการ revoke grant (ไม่มี role ไหน ไม่ว่าจะสิทธิ์สูงแค่ไหน เข้าทาง REST/GraphQL ได้) — แต่ก็หมายความว่าคุณ expose มันทีหลังด้วยgrantอย่างเดียวไม่ได้ ต้องเพิ่ม schema ซึ่งกระทบทุก table ใน schema นั้น ไม่ใช่แค่ตัวเดียว
Common Misconceptions
หัวข้อที่มีชื่อว่า “Common Misconceptions”- “RLS คือ security model ทั้งหมด” มันคือด่านที่สองจากสองด่าน table ที่
enable row level securityแต่ไม่มี grant เลยไม่ได้ “ปลอดภัยกว่า” มันแค่เข้าถึงไม่ได้เฉย ๆ และ table ที่มี grant กว้างแต่ไม่มี policy เข้าถึงได้เต็มที่โดยใครก็ตามที่มี role ตรงกัน - “404 จาก PostgREST แปลว่า table ไม่มีอยู่จริง” มันแปลว่า schema cache ของ PostgREST ไม่มี entry ตรงชื่อนั้นใน schema ที่มันถูกสั่งให้ค้นหา — ซึ่งก็เป็น error เดียวกับที่ได้จาก table ที่มีอยู่จริง grant ครบ แต่อยู่ผิด schema ไปหนึ่งช่อง
- “โปรเจกต์ hosted ของฉันแสดงว่า ‘not exposed by default’ ใน docs แล้ว local dev stack ก็ต้องทำงานเหมือนกัน” พิสูจน์แล้วว่าไม่จริงด้านบน: ณ เวลาที่เขียนนี้
auto_expose_new_tablesที่ CLI scaffold ให้ยัง fallback เป็นtruedocs ที่อธิบาย default ของ hosted ไม่ได้แปลว่าจริงกับsupabase startโดยอัตโนมัติ - “แก้ 404/403 แปลว่าต้องเปลี่ยน grant เสมอ” บางทีแปลว่าต้องเพิ่ม schema เข้า
[api] schemasหรือส่ง headerAccept-Profile/Content-Profileสำหรับ schema ที่ไม่ใช่public— ไม่มีgrantจำนวนไหนแก้ schema ที่ไม่อยู่ในลิสต์ได้