RLS Policy 語法與常見模式 | Supabase 完整教學

2026/09/29
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 五個子句各自的意義
  • using vs with 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 是「讀現有列」→ 只需要 using
  • insert 是「無中生有寫一列新的」→ 沒有現有列可過濾,只需要 with check 驗證新列
  • delete 是「刪掉一列現有的」→ 只需要 using 決定能刪哪些
  • update 最特別,它同時「碰現有列」又「產生新內容」→ 兩者都要:using 決定你能改哪些列、with check 決定你改完之後那列是否還合法

整理成一張你可以貼在螢幕旁的矩陣:

操作usingwith 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(新列條件)五個子句
  • using vs with check:using 管現有列(select/update/delete),with check 管寫入後的新列(insert/update);update 兩者都要且必須另建 select policy
  • auth 與角色:一律 (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 寫對之後,下一步就是把它寫快,下一篇見。

BenZ Software Developer

熱愛技術的軟體開發者,在這裡分享程式開發經驗與學習筆記。

本週主打

AI 自動化入門包

你每天手動在做的那些煩事,其實 AI 可以自己跑。這份給你 10 個照著做就會的自動化工作流 + 50 個複製即用的提示詞,不用會寫程式。

看看這個產品 →