RLS Policy 語法與常見模式 | Supabase 完整教學
上一篇我們搞懂了 RLS(資料列層級安全) 為什麼一定要開、開了之後為何「預設全拒絕」。這一篇正式動手寫 Policy(政策):
create policy的完整語法、using(管現有列)與with check(管新列)到底差在哪、select/insert/update/delete各自怎麼寫、auth.uid()與to authenticated怎麼用,以及 owner-only、public read、team/role-based、soft-delete 四大最實用的模式。把這篇讀完,你就能把「全拒絕」的門一扇扇精準打開。
前言
一句話定義本篇主題:這篇要教你「怎麼寫 RLS Policy」——create policy 的語法骨架、using 與 with check 的分工、針對 select/insert/update/delete 四種操作各自該怎麼寫、auth.uid()/auth.jwt()/to authenticated 如何在 Policy 裡使用,以及四種你幾乎每個專案都會用到的常見模式。
上一篇《Row Level Security 入門》我們建立了整體觀念:資料列層級安全(Row Level Security,簡稱 RLS) 是 PostgreSQL 原生的資料列過濾機制;因為 Supabase 用 PostgREST 把資料庫直接暴露成 API、而 anon key 又是公開的,所以每張 public schema 的表都必須開 RLS;而 enable row level security 只做了一件事——關上所有的門。門關上之後,真正決定「誰能看哪些列、改哪些列」的,就是這一篇的主角:Policy。
先用一個類比接續上一篇的「檔案室管理員」。如果說開 RLS 是「請了一位管理員把門守住」,那 Policy 就是你交給這位管理員的一疊規則卡。每張卡片寫著一條規則:「這種身分的人(to 指定角色)、想做這件事(for 指定操作)時,只能碰符合這個條件(using/with check)的卷宗」。管理員嚴格照卡辦事——你發幾張卡、寫什麼條件,就決定了這張表對外開放的樣貌。沒發卡,管理員預設誰都不放行;發錯卡(例如條件寫太寬),就等於把門大開。
本篇你會學到:
create policy語法骨架:on、for、to、using、with check五個子句各自的意義usingvswith check:一個管「現有列」、一個管「新列」,以及四種操作的對照矩陣- 四種操作各自怎麼寫:
select只要using、insert只要with check、update兩者都要、delete只要using - 四大常見模式:owner-only(只能碰自己的)、public read(公開可讀)、team/role-based(團隊/角色)、soft-delete(軟刪除)
核心概念
create policy 的語法骨架
先把完整語法擺出來,之後所有範例都是它的變形:
create policy policy_name -- Policy 名稱(同一張表內須唯一)
on table_name -- 套用在哪張表
as { permissive | restrictive } -- 選填,預設 permissive(多條以 OR 合併)
for { all | select | insert | update | delete } -- 適用哪種操作
to { role_name | public } -- 適用哪個角色(建議明確指定)
using ( using_expression ) -- 控制「現有列」是否可被操作
with check ( check_expression ); -- 控制「寫入後的列」是否合法
逐個子句拆解,這是你寫每一條 Policy 時都要在腦中跑一遍的清單:
| 子句 | 意義 | 白話 |
|---|---|---|
on | 這條 Policy 套在哪張表 | 「守哪個房間」 |
for | 適用哪種操作(select/insert/update/delete/all) | 「管哪件事」 |
to | 適用哪個角色(authenticated/anon/public) | 「對誰生效」 |
using | 現有列要滿足什麼條件才能被讀/改/刪 | 「哪些卷宗你看得到、碰得到」 |
with check | 寫入後的列要滿足什麼條件才算合法 | 「你寫進去的內容合不合規」 |
其中 using 和 with check 是最容易混淆、也最關鍵的兩個,我們單獨拉出來講。
using vs with check:一個管現有列、一個管新列
這是整篇最重要的觀念,請一定要記牢:
using管「現有的列」,with check管「寫入後的列」。
再講白一點:
using:回答「我能不能看到/碰到這一列既有的資料?」它會被當成一個過濾器,附加在select、update、delete的背後。資料庫要先能「看到」一列,才談得上讀它、改它、刪它。with check:回答「我寫進去的這一列,內容合不合法?」它在insert與update寫入之後檢查結果列,如果不符合條件就直接擋下並報錯。
為什麼是這樣分?想想每種操作的本質:
select是「讀現有列」→ 只需要usinginsert是「無中生有寫一列新的」→ 沒有現有列可過濾,只需要with check驗證新列delete是「刪掉一列現有的」→ 只需要using決定能刪哪些update最特別,它同時「碰現有列」又「產生新內容」→ 兩者都要:using決定你能改哪些列、with check決定你改完之後那列是否還合法
整理成一張你可以貼在螢幕旁的矩陣:
| 操作 | using | with check | 是否需要另建 select policy |
|---|---|---|---|
select | ✅ 必要 | ❌ 不適用 | — |
insert | ❌ 不適用 | ✅ 必要 | 不需要 |
update | ✅ 必要 | ✅ 必要 | 必須(否則靜默失敗) |
delete | ✅ 必要 | ❌ 不適用 | 建議 |
這張表幾乎涵蓋了 RLS Policy 八成的正確性問題。特別提醒 update 那一列:它不但兩個子句都要,還必須另外建一條 select policy,否則 update 會「靜默影響 0 列」——這個坑我們在「常見錯誤」會再細講。
auth.uid()、auth.jwt() 與 to authenticated
在 Policy 的條件式裡,你需要知道「現在是誰在操作」。Supabase 提供幾個函式與角色機制:
| 寫法 | 回傳 | 用途 |
|---|---|---|
(select auth.uid()) | 目前使用者的 UUID(來自 JWT 的 sub) | 擁有者比對,最常用;用 select 包裝以快取 |
auth.jwt() | 完整 JWT payload(jsonb) | 讀取角色、團隊等 claim |
auth.jwt() -> 'app_metadata' ->> 'role' | 伺服器端才能改的角色欄位 | 安全的角色判斷來源 |
auth.jwt() -> 'user_metadata' ->> 'role' | 使用者自己能改的欄位 | 不可用於授權(有安全風險) |
to authenticated | — | 讓這條 Policy 只對「已登入」角色生效 |
三個關鍵細節:
第一,auth.uid() 對未登入者回傳 NULL。 而在 SQL 裡 NULL = user_id 永遠為 false,所以匿名使用者會被自動擋在門外——這是好事,但也代表如果你想開放匿名讀取,不能只靠 auth.uid(),要另外用 to anon 加上明確條件(下面 public read 模式會示範)。
第二,永遠用 (select auth.uid()) 而非裸寫 auth.uid()。 包上 select 後 PostgreSQL 只會求值一次並快取,效能差距可達數十到上千倍。本篇所有範例都採用這個寫法。
第三,授權角色只信 app_metadata,不信 user_metadata。 user_metadata 是使用者自己可以透過 API 修改的,如果你拿它判斷「是不是 admin」,等於讓使用者自己給自己升級權限。角色判斷一律走 app_metadata(伺服器端才能改)或直接查資料庫的角色表。
實作範例
以下 SQL 都可以在 Supabase Dashboard 的 SQL Editor 直接執行。我們沿用上一篇的 todos 概念,並擴充出 posts、projects 等表來示範不同模式。假設每張表都已經 enable row level security。
模式一:Owner-only(使用者只能碰自己的資料)
這是最基本、也最常見的模式:每一列有個 user_id 欄位,使用者只能對「user_id 等於自己」的列做 CRUD。我們逐一操作把四條 Policy 都寫出來,讓你看清每種操作的差別。
先看 select——只要 using:
-- select:只能讀到 user_id 等於自己的列
create policy "todos_select_own"
on public.todos
for select
to authenticated
using ( (select auth.uid()) = user_id );
insert——只要 with check(沒有現有列可過濾,只驗證新列):
-- insert:只能新增「user_id 是自己」的列,防止冒名塞資料
create policy "todos_insert_own"
on public.todos
for insert
to authenticated
with check ( (select auth.uid()) = user_id );
update——using 與 with check 兩者都要:
-- update:using 決定「能改哪些列」,with check 決定「改完後那列是否還合法」
create policy "todos_update_own"
on public.todos
for update
to authenticated
using ( (select auth.uid()) = user_id ) -- 只能改自己的列
with check ( (select auth.uid()) = user_id ); -- 改完 user_id 仍須是自己(防轉讓)
delete——只要 using:
-- delete:只能刪 user_id 等於自己的列
create policy "todos_delete_own"
on public.todos
for delete
to authenticated
using ( (select auth.uid()) = user_id );
四條 Policy 各司其職,這是最推薦的寫法——逐操作分開,清楚且好維護。如果你懶得寫四條,也可以用 for all 一次涵蓋四種操作:
-- 簡化版:for all 一次涵蓋 select/insert/update/delete
-- 注意:using 會套用到 select/update/delete,with check 套用到 insert/update
create policy "todos_all_own"
on public.todos
for all
to authenticated
using ( (select auth.uid()) = user_id )
with check ( (select auth.uid()) = user_id );
for all 寫起來精簡,但缺點是「四種操作被綁成同一條條件」。當不同操作需要不同規則時(例如「大家都能讀,但只能改自己的」),還是得拆開寫。
模式二:Public read(公開可讀、寫入受限)
部落格、論壇這類內容平台最常見的需求:已發布的內容任何人(含未登入)都能讀,但只有作者能寫。重點在 select 這條要對 anon 也開放。
-- select:已發布的貼文對所有人(含匿名)公開,草稿只有作者看得到
create policy "posts_public_read"
on public.posts
for select
to anon, authenticated -- 匿名與登入者都適用
using (
status = 'published' -- 已發布 → 人人可讀
or (select auth.uid()) = author_id -- 或自己是作者 → 草稿也看得到
);
寫入端則鎖死成「只有登入者、且必須把自己設為作者」:
-- insert:只有登入者能發文,且 author_id 必須是自己
create policy "posts_insert_own"
on public.posts
for insert
to authenticated
with check ( (select auth.uid()) = author_id );
-- update:作者才能改自己的貼文(using + with check 雙保險)
create policy "posts_update_own"
on public.posts
for update
to authenticated
using ( (select auth.uid()) = author_id )
with check ( (select auth.uid()) = author_id );
-- delete:作者才能刪自己的貼文
create policy "posts_delete_own"
on public.posts
for delete
to authenticated
using ( (select auth.uid()) = author_id );
注意 select 的 to anon, authenticated 和寫入端的 to authenticated 差異——這就是「讀開放、寫收緊」的關鍵。多條 permissive Policy 之間是 OR 合併,所以 posts_public_read 裡的 status = 'published' or ... author_id 讓「已發布」和「自己是作者」任一成立就能讀。
模式三:Team/role-based(團隊與角色型存取)
多人協作或 SaaS 多租戶場景:使用者屬於某些團隊,只能存取「自己所屬團隊」的資料。假設有一張 team_members(誰在哪個團隊、角色是什麼)和一張 projects(每個專案屬於哪個團隊)。
-- 團隊成員表
create table public.team_members (
team_id uuid not null,
user_id uuid not null references auth.users,
role text not null default 'member', -- 'member' 或 'admin'
primary key (team_id, user_id)
);
-- 效能關鍵:為 policy 會用到的欄位建索引
create index ix_team_members_user_id on public.team_members(user_id);
create index ix_projects_team_id on public.projects(team_id);
團隊成員都能讀所屬團隊的專案。這裡有個方向很重要的寫法——要「以使用者為中心,先找出他加入的所有團隊」,而不是「對每列專案反查使用者在不在」:
-- select:能讀「自己所屬團隊」的專案
create policy "projects_team_read"
on public.projects
for select
to authenticated
using (
team_id in (
select team_id from public.team_members
where user_id = (select auth.uid()) -- 先縮小成「我的團隊集合」
)
);
再加上「只有團隊 admin 能更新專案」的角色型 Policy:
-- update:只有該團隊的 admin 能改專案
create policy "projects_admin_update"
on public.projects
for update
to authenticated
using (
exists (
select 1 from public.team_members
where team_id = projects.team_id
and user_id = (select auth.uid())
and role = 'admin'
)
)
with check (
exists (
select 1 from public.team_members
where team_id = projects.team_id
and user_id = (select auth.uid())
and role = 'admin'
)
);
如果你想用 JWT 裡的角色(透過 Supabase Auth Hook 注入到 app_metadata)做全域管理員判斷,記得走 app_metadata 這條安全來源:
-- 全域 admin(角色存在 JWT 的 app_metadata)可讀所有專案
create policy "projects_global_admin_read"
on public.projects
for select
to authenticated
using ( (auth.jwt() -> 'app_metadata' ->> 'role') = 'admin' );
模式四:Soft-delete(軟刪除:標記而非真刪)
很多系統不真的 delete 資料,而是加一個 deleted_at 欄位,把「刪除」變成「標記」。這樣既能保留稽核紀錄,又能做「垃圾桶」還原。RLS 要配合這個模式:一般讀取只看得到「未被軟刪除」的列。
-- 假設 documents 有 deleted_at 欄位,NULL 代表未刪除
-- select:只看得到自己的、且未被軟刪除的文件
create policy "documents_select_active"
on public.documents
for select
to authenticated
using (
(select auth.uid()) = user_id
and deleted_at is null -- 過濾掉已軟刪除的列
);
-- update(軟刪除本身就是一次 update):允許把自己的文件標記為刪除
create policy "documents_soft_delete"
on public.documents
for update
to authenticated
using ( (select auth.uid()) = user_id ) -- 只能改自己的
with check ( (select auth.uid()) = user_id ); -- 改完仍是自己的
軟刪除的實際「刪除」動作,其實是一條 update:
-- 應用層執行「軟刪除」=把 deleted_at 設為現在時間
update public.documents
set deleted_at = now()
where id = '...';
因為 documents_select_active 的 using 帶了 deleted_at is null,這列被標記後就從一般查詢中「消失」了,但資料仍在。若要做「垃圾桶」頁面,你可以另建一條只給本人、專門撈 deleted_at is not null 的 select policy。
常見錯誤與最佳實踐
坑一:insert 忘了寫 with check,或誤用 using。
insert 沒有「現有列」,所以它不吃 using、只吃 with check。新手常照著 select 的樣子寫 using,結果條件根本沒生效(insert 會被擋或行為不如預期)。正確做法:insert 一律用 with check 驗證新列,且務必加上 with check ( (select auth.uid()) = user_id ) 之類條件,否則使用者能冒名塞入「別人的」資料。
-- ❌ 錯誤:insert 用 using(不會生效)
create policy "bad_insert" on public.todos
for insert to authenticated
using ( (select auth.uid()) = user_id ); -- insert 根本不看 using
-- ✅ 正確:insert 用 with check
create policy "good_insert" on public.todos
for insert to authenticated
with check ( (select auth.uid()) = user_id );
坑二:update 只設 using,忘了 with check。
只寫 using 的 update policy,會允許使用者「把自己的列改成別人的」。例如 update todos set user_id = '別人的 uuid'——因為 using 只檢查「改之前這列屬於我」,改之後的歸屬沒人管,資料就被轉讓(甚至讓自己看不見)出去了。正確做法:update 一定要 using + with check 雙保險,with check 確保「改完之後那列仍然屬於我」。
-- ❌ 不完整:可以把 user_id 竄改成別人的
create policy "bad_update" on public.todos
for update to authenticated
using ( (select auth.uid()) = user_id );
-- ✅ 完整:改完後 user_id 仍須是自己
create policy "good_update" on public.todos
for update to authenticated
using ( (select auth.uid()) = user_id )
with check ( (select auth.uid()) = user_id );
坑三:有 update policy 卻沒有 select policy,導致靜默失敗。
PostgreSQL 執行 update 前得先「看到」目標列,而「看到」走的是 select policy。沒有 select policy → 找不到可更新的列 → 靜默回傳影響 0 列,不報錯。這是最難察覺的坑,因為程式沒噴 error。正確做法:只要一張表有 update,就一定要有對應的 select policy(條件通常和 update 的 using 一樣)。
坑四:Policy 條件太寬,等於沒設。
測試時圖方便寫了 using (true) 或 with check (true),上線忘了收回,等於這張表對該角色門戶大開。正確做法:using (true) 這類「全開」條件只能用於「本來就該公開」的資料(例如公開文章的 select);任何涉及隱私的表都不該出現無條件的 true。上線前務必逐條檢視 Policy 的條件。
坑五:裸寫 auth.uid() 不包 select。
auth.uid() = user_id 會對每一列重新求值,大表下效能崩潰。正確做法:一律 (select auth.uid()) = user_id。這雖屬效能議題,但寫法成本為零,應該從第一天就養成習慣。
最佳實踐總結:
- 逐操作、逐角色寫 Policy:
select/insert/update/delete分開,比for all更清楚可控 - 牢記操作矩陣:select→using、insert→with check、update→兩者+另建 select、delete→using
- update 必配 with check,防止使用者竄改歸屬欄位(如
user_id/author_id) - 授權角色只信
app_metadata,絕不用user_metadata判斷權限 - 一律
(select auth.uid())包裝,並為 Policy 用到的欄位建索引 - 上線前逐條檢視,揪出
using (true)、缺 select policy、缺 with check 等問題
小結
這是 Supabase 系列教學的第 010 篇,承接上一篇《Row Level Security 入門》——上一篇我們搞懂了「為什麼要開 RLS、開了為何預設全拒絕」,這一篇則正式動手,把「全拒絕」的門一扇扇用 Policy 精準打開。
create policy骨架:on(哪張表)、for(哪種操作)、to(哪個角色)、using(現有列條件)、with check(新列條件)五個子句usingvswith check:using管現有列(select/update/delete),with check管寫入後的新列(insert/update);update兩者都要且必須另建 select policyauth與角色:一律(select auth.uid())包裝、授權只信app_metadata、用to authenticated/to anon精準指定角色- 四大模式:owner-only(碰自己的)、public read(公開可讀寫收緊)、team/role-based(團隊與角色)、soft-delete(軟刪除靠
deleted_at)
一句話記住本篇:Policy 是你交給 RLS 管理員的規則卡——using 管看得到的、with check 管寫進去的,四種操作各有各的填法。
到這裡你已經會寫出正確的 Policy 了。但「正確」和「快」是兩回事——當資料量上到數十萬、上百萬列,寫法的細節會讓查詢從幾毫秒暴增到好幾秒。下一篇《RLS 效能最佳化與陷阱》,我們會深入 (select auth.uid()) 為何能快上千倍、Policy 欄位為什麼一定要建索引、JOIN 方向如何決定生死,以及用 security definer function 封裝複雜授權邏輯的技巧。把 Policy 寫對之後,下一步就是把它寫快,下一篇見。