· 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 對正式資料庫跑,流程是:
- 用 admin API 建一個臨時帳號。
- 以非成員身分登入:每張表都要讀到 0 列,寫入每一類表都要失敗;刪除只拿一列測試用的哨兵資料來試,萬一規則有漏,也碰不到真的資料。
- 用匿名身分:只讀得到已發布的文章。
- 由我把他加成成員:他看到的每張表筆數要跟我完全一樣,新增的列
owner_id要是我。 - 成員不能把列寫在自己名下、不能把列搬出工作區、不能把自己升成 owner。
- 把他移出成員表:立刻什麼都讀不到。
- 刪掉臨時帳號。
正式套用 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)
- Supabase:Tables and Data
- Supabase:Row Level Security
- Supabase:RLS Performance and Best Practices
- Supabase:Securing your API
- Supabase:API keys
- Supabase:Testing Your Database、pgTAP
- Supabase:Database Advisors
- PostgreSQL:CREATE POLICY、Row Security Policies