外觀

語言

· 14 分鐘閱讀 · Supabase 資料庫 資安 教學

Supabase RLS 教學:policy 寫法、using 與 with check、怎麼測

Supabase RLS 繁體中文教學:開啟 RLS、四種指令的 policy、using 與 with check 的差別、(select auth.uid()) 的效能、security definer 的風險與測試方法,附多人工作區的實際寫法。

Supabase RLS(Row Level Security,列層級安全)是 Postgres 內建的權限機制:每張表開啟 RLS 之後,再替 select、insert、update、delete 各寫一條 policy,資料庫會依登入的人逐列判斷看得到哪些資料、能寫入哪些資料。前端拿著公開的 publishable key 直接查資料庫,真正擋住資料的就是這幾條 policy。例子都來自我網站後台實際在跑的規則,包括我踩過的坑。

技術說明以 Supabase 與 PostgreSQL 官方文件為準,查核日 2026 年 11 月 3 日。

RLS 在擋什麼

Supabase 會把資料庫裡的表變成 API(Data API),瀏覽器裡的 supabase-js 查資料,最後都是以 anon(沒登入)或 authenticated(已登入)這兩個 Postgres 角色執行 SQL。這兩個角色能不能碰某張表,要過兩道門:

  • 權限(grant):這個角色對這張表有沒有 select、insert 等權限。
  • RLS:有權限之後,哪幾列可以讀、可以寫。

我的文章表 migration 裡有一行註解,剛好把分工講清楚:

-- Grants gate; RLS filters. Anon may only read; authenticated may write.
alter table public.blog_posts enable row level security;
grant select on public.blog_posts to anon;
grant select, insert, update, delete on public.blog_posts to authenticated;

grant 決定能不能做這個動作,RLS 決定這個動作作用在哪幾列。

開啟 RLS:一行指令,然後預設全部拒絕

alter table public.notes enable row level security;

開了之後還沒寫任何 policy,用 publishable key 查這張表就什麼都拿不到。PostgreSQL 文件稱這是「default deny」:有開 RLS、沒有適用的 policy,就看不到也改不了任何一列。

要注意建表的方式。Supabase 官方說明,在後台 Table Editor 建的表預設開啟 RLS;用 SQL Editor 或其他工具建的表,要自己下 enable row level security。我的表都是寫 migration 建的,所以每份 migration 都有這一行。

四種指令的 policy

以筆記表為例,每個人只能管自己的筆記,這是 Supabase 文件裡最常見的寫法:

create table public.notes (
  id      uuid primary key default gen_random_uuid(),
  user_id uuid not null default auth.uid() references auth.users(id) on delete cascade,
  body    text not null
);
alter table public.notes enable row level security;

create policy "讀自己的筆記" on public.notes for select to authenticated
  using ((select auth.uid()) = user_id);
create policy "新增自己的筆記" on public.notes for insert to authenticated
  with check ((select auth.uid()) = user_id);
create policy "修改自己的筆記" on public.notes for update to authenticated
  using ((select auth.uid()) = user_id)
  with check ((select auth.uid()) = user_id);
create policy "刪除自己的筆記" on public.notes for delete to authenticated
  using ((select auth.uid()) = user_id);

create index notes_user_id_idx on public.notes (user_id);
指令 用哪個子句 檢查的是
select using 現有的列能不能讀
insert with check 要寫進去的新列合不合規則
update using + with check using 決定能改哪幾列,with check 檢查改完之後的樣子
delete using 現有的列能不能刪
  • to authenticated 一定要寫。Supabase 建議每條 policy 都標明角色;沒標的話,沒登入的 anon 也會跑一次這條 policy 的判斷。官方測試裡,anon 存取時加上 to authenticated,從 170 ms 降到不到 0.1 ms。
  • 沒登入時 auth.uid() 回傳 null,null = user_id 永遠不成立,所以不會誤放,但也不會報錯,只是查不到東西。
  • policy 用到的欄位要加索引。官方的效能測試裡,user_id 加索引前後是 171 ms 對不到 0.1 ms。

也可以用 for all 一條包四種指令,我的後台就是這樣寫。

using 和 with check 差在哪

using 檢查「已經在表裡的列」,with check 檢查「即將寫進去的列」。兩者失敗的方式也不同:

  • using 不通過,那一列直接被濾掉,不會報錯。select 少了幾列、update 回傳 0 列,都是這種情況。
  • with check 不通過,整個 insert 或 update 失敗,回傳錯誤。

PostgreSQL 文件還寫了兩件常被忽略的事:

  • update 通常也要「看得到」那一列:只要 update 帶 where 條件或回傳資料(supabase-js 的 .eq()、.select() 都算),除了 update policy,還要有一條 select 或 all policy 涵蓋那一列。Supabase 文件直接寫成:做 update 就要有對應的 select policy。
  • for all 或 update 只寫 using、沒寫 with check 時,using 也會拿來當 with check。

with check 擋的是「把列改到別人名下」。例如上面的筆記表,update 只寫 using 也剛好安全,因為條件一樣;但條件不一樣時,少寫 with check 就可能讓人把自己的列搬走,或改成別人看得到的狀態。

我的規則:一個工作區、多個成員

我的網站後台(/app)原本只有我一個人用,大部分的表都是最常見的寫法:

create policy "Owner manages own projects" on public.projects
  for all to authenticated
  using (owner_id = auth.uid()) with check (owner_id = auth.uid());

另外幾張舊表更簡單:當時沒有 owner 欄位,migration 註解寫著「單一使用者,authenticated 就等於唯一的擁有者」,policy 直接是 using (true)。

今年 9 月我要加一個同事的帳號來處理 SEO,他的權限要跟我一樣。設計文件 spec/2026-09-29-workspace-members-design.md 第一節把問題列出來:

層 原本 加了第二個帳號之後
資料(RLS) 多數表 owner_id = auth.uid();幾張舊表 using (true) 他看不到 Studio 和文章,卻能透過 API 讀寫刪那幾張舊表

兩個問題方向相反。owner_id = auth.uid() 太嚴,資料都在我名下,他的 auth.uid() 對不上任何一列;using (true) 太鬆,任何登入的帳號都能動那幾張表。原本會沒事,是因為只有我一個帳號,這個前提一變,兩條規則都錯了。

我的做法是加一張成員表,再用輔助函式把「是不是成員」和「工作區擁有者是誰」包起來。下面是精簡版,函式名稱和 schema 改成通用的寫法,結構跟我實際的 migration 一樣:

create table public.members (
  user_id    uuid primary key references auth.users(id) on delete cascade,
  role       text not null check (role in ('owner', 'member')),
  created_at timestamptz not null default now()
);

create schema if not exists private;
grant usage on schema private to authenticated;

create or replace function private.is_member() returns boolean
language sql stable security definer set search_path = '' as $$
  select exists (select 1 from public.members where user_id = auth.uid())
$$;

create or replace function private.workspace_owner_id() returns uuid
language sql stable security definer set search_path = '' as $$
  select user_id from public.members where role = 'owner'
$$;

這兩個是 security definer 函式,會繞過 RLS。官方建議放在不對 API 開放的 schema;放在 public 的話,執行權限和回傳內容要照後面的原則收緊。

成員表本身還需要第三個函式 private.is_owner()(寫法同上,多一個 role = 'owner' 條件),用在成員表的 policy:所有成員都看得到名單,但新增、修改、移除只有 owner 能做,而且只能動 member 那幾列。成員不能把自己升成 owner,owner 那一列誰都改不了:

create policy "Owner adds members" on public.members
  for insert to authenticated
  with check ((select private.is_owner()) and role = 'member');

有 owner 欄位的表,policy 一律長這樣(同樣是精簡版):

create policy "Members manage studio_contents" on public.studio_contents
  for all to authenticated
  using ((select private.is_member()) and owner_id = (select private.workspace_owner_id()))
  with check ((select private.is_member()) and owner_id = (select private.workspace_owner_id()));

alter table public.studio_contents alter column owner_id set default private.workspace_owner_id();
  • is_member():登入的人在成員表裡,才看得到東西。不是成員的帳號,每張表都讀到 0 列。
  • owner_id = workspace_owner_id():列必須屬於工作區。對我來說,意思跟原本的 owner_id = auth.uid() 一樣。
  • with check 寫同一個條件:成員不能把新列寫在自己名下,也不能把現有的列 update 到工作區外面。
  • 預設值改成 workspace_owner_id():同事新增的列也屬於工作區,背景 worker 和其他「owner 必須一致」的檢查都不用改。

沒有 owner 欄位的表,只看成員資格:using ((select private.is_member())) with check ((select private.is_member()))。

這條規則我寫進了專案的 CLAUDE.md,之後每張新表都照這個寫法,並且註明不要用 owner_id = auth.uid()。

第一版的 migration 其實只檢查成員資格,沒有 owner 的條件。review 時才補上:只檢查成員資格的話,成員可以 insert 一列 owner_id 是自己的資料,或把現有的列改到自己名下,這些列就離開了工作區。with check 加上 owner 條件,才把這個洞關起來。

為什麼要包成 (select …)

上面每個函式呼叫都包在 (select …) 裡,auth.uid() 也一樣。差別在執行次數:直接寫 auth.uid() = user_id,Postgres 可能每一列都呼叫一次函式;包成 (select auth.uid()),優化器會把它變成 initPlan,同一個查詢只算一次,結果重複使用。

Supabase 官方的 RLS 效能文件有實測數字:

寫法 改寫前 改寫後
auth.uid() = user_id → (select auth.uid()) = user_id 179 ms 9 ms
is_admin()(函式內查別的表)→ (select is_admin()) 11,000 ms 7 ms
has_role() = role → (select has_role()) = role 178,000 ms 12 ms

限制是函式結果不能隨著每一列改變。is_member() 和 workspace_owner_id() 只看目前登入的人,跟是哪一列無關,所以可以包。Supabase 的 Performance Advisor 有一條 auth_rls_initplan(Slow security policy detected),抓的就是沒包起來的寫法。

security definer 輔助函式:好用,但有代價

is_member() 要查 members,而 members 自己也開了 RLS。如果用一般函式,policy 裡查成員表、成員表的 policy 又要判斷成員資格,會繞回自己。設計文件的決定是用 security definer:函式以建立者的權限執行,跳過 members 的 RLS,不會遞迴。PostgreSQL 文件也寫到,policy 的運算式以查詢者的權限執行,security definer 函式可以用來讀取查詢者本身讀不到的資料。

方便的地方就是風險所在:這個函式繞過了 RLS,裡面寫什麼,呼叫的人就能拿到什麼。我照官方建議做了這幾件事:

  • set search_path = '',函式裡的表名都寫完整的 public.members,避免有人用同名物件劫持。
  • 函式只回傳最少的資訊,不回傳整列資料。
  • 收回預設的執行權限,只開給 authenticated:
revoke all on function private.is_member(), private.is_owner(), private.workspace_owner_id() from public, anon;
grant execute on function private.is_member(), private.is_owner(), private.workspace_owner_id() to authenticated;

Supabase 的 Data API 文件提醒,函式預設每個角色都能呼叫,RLS 也管不到函式,所以這一步要自己做。

security definer 函式還有一條官方建議:不要放在對 API 開放的 schema(例如 public)。放在那裡的函式,任何拿得到對應角色的請求都能透過 Data API 直接呼叫,而且是以建立者的權限執行。如果應用程式真的需要從 API 呼叫某個判斷函式,至少做到兩件事:收回 anon 的執行權限;回傳值只跟呼叫者自己有關(例如「我是不是成員」的布林值),讓任何登入的人看到也無妨。能放進不對外的 schema 就放進去。Security Advisor 的 authenticated_security_definer_function_executable 會列出 authenticated 能執行的 security definer 函式,值得逐條看過。

怎麼測 policy

policy 寫錯最常見的結果是「沒有錯誤、但查到 0 列」,光看程式不會發現,一定要用不同身分實際跑過。

Supabase 官方提供兩種方式:用應用程式的 client 寫測試,或用 pgTAP 寫 SQL 測試,放在 supabase/tests/database/,用 supabase test db 執行。pgTAP 有 policies_are() 這類函式,可以檢查一張表上的 policy 是不是剛好就是你預期的那幾條。

我用的是第一種。tests/e2e/workspace-members.mjs 用 supabase-js 對正式資料庫跑,流程是:

  1. 用 admin API 建一個臨時帳號。
  2. 以非成員身分登入:每張表都要讀到 0 列,寫入每一類表都要失敗;刪除只拿一列測試用的哨兵資料來試,萬一規則有漏,也碰不到真的資料。
  3. 用匿名身分:只讀得到已發布的文章。
  4. 由我把他加成成員:他看到的每張表筆數要跟我完全一樣,新增的列 owner_id 要是我。
  5. 成員不能把列寫在自己名下、不能把列搬出工作區、不能把自己升成 owner。
  6. 把他移出成員表:立刻什麼都讀不到。
  7. 刪掉臨時帳號。

正式套用 migration 之前,我先用 PGlite 在本機跑過全部 migration,模擬 owner、成員、非成員、anon 四種身分;套用之後,這份 e2e 測試 9 項全過。

migration 本身也有一道檢查。固定清單可能漏掉手動建的 policy 或表,所以 migration 結尾會查 pg_policies 和 pg_class,只要有給 authenticated 的 policy 沒經過成員函式,或 public 裡有沒開 RLS、卻讀得到的表,整個 migration 就 rollback:

select string_agg(schemaname || '.' || tablename || ' / ' || policyname, '; ') into bad
  from pg_policies
 where schemaname in ('public', 'storage')
   and roles && array['authenticated', 'public']::name[]
   and coalesce(qual, '') || coalesce(with_check, '') !~ '(is_member|is_owner)\(\)';
if bad is not null then raise exception 'workspace_members: policies outside the member rule: %', bad; end if;

最後別忘了後台的 Security Advisor 和 Performance Advisor,CLI 也能跑 supabase db advisors。

常見的錯誤

  • 新表忘了開 RLS。Supabase 文件說明,既有專案在 public 新建的表,預設就把權限給了 anon、authenticated、service_role;表在對外的 schema、又沒開 RLS,有權限的角色就能讀寫。我自己遇過:Studio 的幾張表 migration 從來沒 grant 給 anon,建好之後 anon 卻有權限。當時 RLS 已經開著、policy 只給 authenticated,所以沒有外洩,我還是另外寫了一份 migration 把 anon 的權限收回。Data API 設定裡可以關掉「Automatically expose new tables」。Security Advisor 的 rls_disabled_in_public 是 ERROR 等級。
  • 以為 service role 也要過 RLS。secret key(取代舊的 service_role key)對應的 service_role 角色有 bypassrls,完全不看 policy。它只能放在伺服器端,絕對不能出現在瀏覽器。官方文件也寫到,請求帶著使用者的 access token 時,secret key 不會繞過 RLS。
  • view 繞過 RLS。view 預設以建立者(通常是 postgres)的權限執行,底層表的 RLS 不會套用。Postgres 15 以上建 view 時加 with (security_invoker = on),它才會照查詢者的身分套用底層表的 policy。我後台的彙總 view 都這樣建,所以底層表改了 policy,view 自動跟著變。Security Advisor 的 security_definer_view 會抓沒加的。
  • 用 using (true) 給 authenticated。所有登入的人都能讀寫。我有幾張舊表就是這樣寫了好幾個月,直到要加第二個帳號才發現。
  • policy 讀 user_metadata。使用者自己就能改 user metadata,拿來判斷權限等於讓人自己決定權限。Advisor 的 rls_references_user_metadata 是 ERROR 等級。
  • 同一個指令寫了好幾條 permissive policy。PostgreSQL 會用 OR 把它們合起來,只要一條放行就放行;舊的寬鬆 policy 沒刪乾淨,新的嚴格 policy 就形同虛設。我的 migration 先把那些表上所有給 authenticated 的 policy 刪掉,再建新的,就是為了這個。
  • 把 RLS 當查詢條件用。Supabase 建議前端查詢時,仍然加上 .eq('user_id', …) 這類條件。官方測試裡,同一條 policy 加上查詢條件後,從 171 ms 降到 9 ms。RLS 負責擋,查詢條件負責篩,兩個都要寫。

常見問題

Supabase 一定要開 RLS 嗎?

只要表會透過 Data API 被瀏覽器存取,就要開。Supabase 官方的說法是,所有透過 Data API 公開的表和 view 都應該開 RLS。我網站裡只讓伺服器用 secret key 存取的表,也照樣開 RLS、不寫任何給 anon 和 authenticated 的 policy,預設就是拒絕。

RLS 會讓查詢變慢嗎?

policy 寫法不對會,差距可能是幾百倍。函式包成 (select …)、policy 用到的欄位加索引、標明 to authenticated,大部分的成本就消失了。

費用會不會受影響?

RLS 本身不另外收費,但寫得慢的 policy 會吃掉資料庫的運算資源。Supabase 的費用怎麼算,可以看Supabase 費用怎麼算。

我的 migration 和 policy 大多是和 AI 一起寫的。AI 透過 Supabase MCP 執行 SQL 時,刪表、清空資料這類語句會先被 hook 攔下來給我確認,設定方式寫在 Claude Code 教學。權限規則我還是會一條一條看:policy 寫錯不會報錯,只會讓資料安靜地多給或少給。

參考資料(查核日 2026-11-03)