Supabase Storage 權限與存取控制:用 RLS 守好每個檔案 | Supabase 完整教學
上一篇你學會了建 bucket、上傳與取用檔案,但存取控制還很粗:public 全公開、private 靠簽名連結。這一篇要把你在 009–011 篇熟透的 RLS 下沉到 Storage——原來 Supabase Storage 的權限就是 Postgres RLS,政策全寫在
storage.objects資料表上,依bucket_id與路徑第一層資料夾授權。我們會實作「每人只能存取自己資料夾」「公開讀、私密寫」等常見模式,並釐清 public bucket 與 signed URL 各自的角色。
前言
一句話定義本篇主題:Supabase Storage 的權限與存取控制,就是把 Row Level Security(RLS)套到 storage.objects 這張資料表上——你為「上傳、讀取、更新、刪除」四種操作分別寫政策,依 bucket_id(哪個 bucket)與 name(檔案路徑)判斷「這個人能不能對這個檔案做這件事」。 Storage 沒有另一套權限機制,它借用的正是你已經很熟的那套 RLS。
先接住一個關鍵事實:Storage 的每個檔案,在 Postgres 裡都有一筆對應的「戶口」。 你在上一篇上傳的每個檔案,Supabase 都會在 storage.objects 這張系統資料表裡記一行——包含它的完整路徑(name)、所屬 bucket(bucket_id)、大小、MIME type 等。既然檔案的中繼資料就是一張資料表的一行行資料,那麼「控制誰能碰哪一行」這件事,當然就能用你在 009–011 篇學過的 RLS 政策來做。Storage 權限的本質,是對 storage.objects 這張表做行級授權。
用一個生活類比幫你抓住全貌:Storage 的權限系統,就像一棟「每戶都有門禁卡」的公寓大樓。
- 大樓(bucket) 分兩種:「開放參觀的展示大樓」(public bucket,大廳誰都能進去看)和「純住宅大樓」(private bucket,非住戶進不來)。
- 每一戶(檔案的第一層資料夾) 用住戶編號命名,例如
8f3a-1b2c/(就是某人的user_id)。 - 門禁政策(RLS policy) 寫在大樓管理室(
storage.objects):規則是「你的門禁卡只能開『卡號等於你戶號』的那一戶」——這就是(storage.foldername(name))[1] = auth.uid()。 - 展示大樓的大廳(public 讀取) 對外開放參觀,但「搬東西進去、丟東西出來」(寫入)仍要刷卡。
- 臨時訪客通行證(signed URL) 是住宅大樓發給訪客的「限時門卡」,過期作廢。
抓住「權限寫在 storage.objects、靠 bucket_id + 路徑第一層授權、public 只放寬讀取、signed URL 管限時對外」這四件事,Storage 存取控制就通了。
本篇你會學到:
- Storage RLS 的本質:為什麼權限寫在
storage.objects資料表,四種操作(INSERT/SELECT/UPDATE/DELETE)如何對應「上傳/讀取/覆寫/刪除」 - 路徑授權的核心語法:
(storage.foldername(name))[1] = (select auth.uid()::text)逐字拆解,以及storage.filename、storage.extension輔助函式 - 常見權限模式:每人只能存取自己資料夾、公開讀私密寫、依副檔名限制、結合資料表查角色
- public bucket 與 signed URL 的定位:public 為何繞過讀取政策、signed URL 在授權體系中扮演什麼角色
- 常見坑:沒設政策全鎖、public bucket 放私密檔、路徑函式與型別用錯
核心概念
Storage 存取控制只要掌握三張心智圖:一張是「政策住在哪張表、怎麼對應操作」,一張是「路徑怎麼變成授權判斷」,一張是「public bucket 與 signed URL 在體系裡的位置」。
一、政策住在 storage.objects,依操作分開寫
Supabase Storage 的所有 RLS 政策,都定義在 storage.objects 這張資料表上。 這張表由 Supabase 自動維護,你每上傳一個檔案,它就多一行。既然是資料表,就能像 009–011 篇那樣對它下 RLS 政策——差別只在於,政策要依「操作類型」分開寫,而 Storage 的每個 SDK 動作,背後其實對應到一組 Postgres 權限:
| Storage 動作 | 對應 SDK 方法 | 所需 Postgres 權限 |
|---|---|---|
| 上傳新檔案 | upload() | INSERT |
| 覆寫既有檔案 | upload({ upsert: true }) | SELECT + INSERT + UPDATE |
| 列出/下載檔案 | list() / download() / signed URL | SELECT |
| 刪除檔案 | remove() | DELETE |
| 移動/重新命名 | move() | SELECT + UPDATE |
這張表最容易被忽略的一行是**「覆寫」需要三種權限**(SELECT + INSERT + UPDATE)——很多人只設了 INSERT,結果 upsert: true 一直失敗,就是漏了 SELECT 和 UPDATE。
最關鍵的預設行為:block by default(預設全鎖)。 只要你為某個 bucket 建了檔案卻沒設任何政策,所有操作都會被 RLS 擋下(
new row violates row-level security policy)。Storage 的預設不是「全開」,而是「全關」——你必須主動為每一種你想放行的操作寫政策。
二、路徑如何變成授權判斷
Storage RLS 政策裡最常出現、也最該理解透徹的一行,是這個:
(storage.foldername(name))[1] = (select auth.uid()::text)
把它一節一節拆開:
name ← 檔案在 bucket 內的完整路徑
例如:8f3a-1b2c/2026-report.pdf
storage.foldername(name) ← 把路徑切成「資料夾陣列」
→ ARRAY['8f3a-1b2c']
(storage.foldername(name))[1] ← 取陣列第 1 層(Postgres 陣列從 1 開始)
→ '8f3a-1b2c'
(select auth.uid()::text) ← 目前登入者的 user_id,轉成文字
→ '8f3a-1b2c'
兩者相等 → 放行;不相等 → 擋下
這一行的效果就是:使用者只能碰「路徑第一層資料夾等於自己 user_id」的檔案。 別人的資料夾(第一層是別人的 user_id)一律進不去。這也解釋了上一篇為什麼一直強調「路徑要用 user_id/檔名 慣例」——因為 RLS 就是靠路徑的第一層資料夾來認人的。
除了 foldername,還有兩個輔助函式常在政策裡用到:
| 函式 | 範例輸入 | 回傳 | 用途 |
|---|---|---|---|
storage.foldername(name) | 'user1/sub/a.jpg' | ARRAY['user1','sub'] | 取資料夾層級([1] 取第一層做擁有者判斷) |
storage.filename(name) | 'user1/a.jpg' | 'a.jpg' | 取純檔名(不含路徑) |
storage.extension(name) | 'user1/a.jpg' | 'jpg' | 取副檔名(用來限制上傳型別) |
一個型別小地雷:auth.uid() 回傳的是 uuid 型別,而 name 拆出來是 text,兩者不能直接比較,所以要寫成 auth.uid()::text 轉型。
三、public bucket 與 signed URL 的定位
初學者最常混淆的,是「bucket 的 public 開關」「RLS 政策」「signed URL」三者的關係。一張圖釐清:
┌─────────── 讀取(SELECT) ───────────┐
│ │
public bucket ─────┤ 繞過 SELECT 政策,任何人可用 │
│ getPublicUrl 直接讀(等於公開) │
│ │
private bucket ────┤ 受 SELECT 政策管;想給外部看 │
│ → 用 signed URL 發限時通行證 │
└──────────────────────────────────────┘
寫入(INSERT / UPDATE / DELETE)
└─→ 無論 public 或 private,一律受 RLS 政策管,不例外
三個要點:
- public 只放寬「讀取」:把 bucket 設 public,等於對 SELECT(讀)繞過政策——任何人拿
getPublicUrl的網址都能開。但寫入(上傳/覆寫/刪除)完全不受影響,還是得寫 RLS 政策。 - private bucket 的讀取受 SELECT 政策管:想讓某個檔案被讀到,要嘛登入者符合 SELECT 政策(用
download/list),要嘛用createSignedUrl發一條繞過政策、限時有效的簽名網址給外部。 - signed URL 是「臨時、對外」的授權出口:它不改政策,而是由伺服器端(或符合政策的登入者)替某個路徑簽出一條限時網址,讓沒有登入、不符政策的人也能在期限內讀到那一個檔案。它是 private bucket 對外分享的正規管道。
四、關鍵術語小抄
| 術語 | 意義 |
|---|---|
storage.objects | 記錄每個檔案中繼資料的系統資料表,所有 Storage RLS 政策寫在這裡 |
bucket_id | 政策用來限定「這條規則管哪個 bucket」的欄位 |
name | 檔案在 bucket 內的完整路徑(如 user_id/file.pdf) |
storage.foldername(name) | 把路徑切成資料夾陣列,[1] 取第一層做擁有者判斷 |
| block by default | 預設全鎖:沒設政策 = 全部操作被擋(非全開) |
| public bucket | 只對「讀取」繞過政策;寫入仍受 RLS 管 |
| signed URL | 對 private bucket 檔案發的限時、可對外的簽名網址 |
service_role | 繞過所有 RLS 的伺服器端金鑰,絕不可放前端 |
實作範例
以下用可直接執行的 SQL,從最基本到進階,示範五種常見權限模式。這些 SQL 可貼到 Supabase Dashboard 的 SQL Editor,或寫進 migration。假設我們有一個 private 的 documents bucket 和一個 public 的 avatars bucket。
一、最基本:已登入者可上傳到指定 bucket
先從最簡單的 INSERT 政策開始——「任何登入使用者都能上傳到 documents」。這適合團隊內部共享空間,但還沒做到「只能碰自己的」:
-- INSERT 政策:已登入者可上傳到 documents bucket
create policy "Authenticated can upload to documents"
on storage.objects
for insert
to authenticated -- 只對已登入角色開放
with check (
bucket_id = 'documents' -- 限定這條政策只管 documents 這個 bucket
);
三個要點:一、on storage.objects 表示政策掛在 Storage 的系統表上;二、for insert + to authenticated 表示「這條只管『登入使用者的上傳』」;三、INSERT 政策用的是 with check(檢查「要寫入的新資料」是否合規),而非 using——這點跟 009–011 篇的 RLS 一致。
二、核心模式:每人只能存取自己的資料夾
這是 Storage 最經典、也最該背下來的模式。要讓「使用者只能碰自己 user_id 資料夾下的檔案」,需要為四種操作分別寫政策(這裡示範 INSERT / SELECT / DELETE 三種最常用的):
-- 上傳:只能上傳到「第一層資料夾 = 自己 user_id」的路徑
create policy "Users can upload to own folder"
on storage.objects
for insert
to authenticated
with check (
bucket_id = 'documents'
and (storage.foldername(name))[1] = (select auth.uid()::text)
);
-- 讀取:只能讀自己資料夾下的檔案
create policy "Users can read own files"
on storage.objects
for select
to authenticated
using (
bucket_id = 'documents'
and (storage.foldername(name))[1] = (select auth.uid()::text)
);
-- 刪除:只能刪自己資料夾下的檔案
create policy "Users can delete own files"
on storage.objects
for delete
to authenticated
using (
bucket_id = 'documents'
and (storage.foldername(name))[1] = (select auth.uid()::text)
);
搭配這組政策,前端上傳時的路徑就必須以 user_id 開頭:
// 前端上傳:路徑第一層一定要是自己的 user_id,才過得了 with check
async function uploadMyDoc(userId: string, file: File) {
const filePath = `${userId}/${crypto.randomUUID()}-${file.name}`
const { data, error } = await supabase
.storage
.from('documents')
.upload(filePath, file)
// 若路徑第一層不是自己的 user_id → 被 RLS 擋,error 為 403
if (error) throw error
return data.path
}
重點在 SELECT 與 DELETE 用 using(檢查「既有資料列」是否可見/可刪),INSERT 用 with check(檢查「新資料列」是否合規)。三條政策的授權條件一致,共同構成「每人自掃門前雪」的封閉圈。
三、公開讀、私密寫:avatars 大頭照的典型組合
大頭照是最常見的「公開讀、私密寫」場景:任何人都能看到別人的頭像(讀公開),但只有本人能上傳/換掉自己的頭像(寫入受限)。這需要 avatars 是 public bucket,再加上「只能寫自己資料夾」的寫入政策:
-- 前提:avatars 是 public bucket(讀取繞過政策,任何人可用 getPublicUrl 看)
-- 因此「讀取」不必寫 SELECT 政策;只要管好「寫入」即可。
-- 上傳:只能上傳到自己 user_id 資料夾
create policy "Users can upload own avatar"
on storage.objects
for insert
to authenticated
with check (
bucket_id = 'avatars'
and (storage.foldername(name))[1] = (select auth.uid()::text)
);
-- 覆寫(換頭像用 upsert:true,需要 UPDATE 政策)
create policy "Users can update own avatar"
on storage.objects
for update
to authenticated
using (
bucket_id = 'avatars'
and (storage.foldername(name))[1] = (select auth.uid()::text)
)
with check (
bucket_id = 'avatars'
and (storage.foldername(name))[1] = (select auth.uid()::text)
);
注意 UPDATE 政策同時需要 using 和 with check:using 檢查「你有沒有資格改這個既有檔案」,with check 檢查「改完後的新狀態合不合規」。缺一個,upsert: true 換頭像就會失敗。至於「讀」——因為 avatars 是 public,任何人用 getPublicUrl 就能看到所有人的頭像,不必也不需要寫 SELECT 政策。
四、依副檔名限制上傳型別
除了「誰能上傳」,你常常也想限制「能上傳什麼」。用 storage.extension() 在 with check 裡卡副檔名,就能只放行圖片:
-- 只允許上傳圖片副檔名到 avatars
create policy "Only image files allowed"
on storage.objects
for insert
to authenticated
with check (
bucket_id = 'avatars'
and (storage.foldername(name))[1] = (select auth.uid()::text)
and storage.extension(name) in ('jpg', 'jpeg', 'png', 'webp', 'gif')
);
這條政策把「只能寫自己資料夾」和「只能傳圖片」兩個條件用 and 串起來——上傳一個 .pdf 到自己資料夾也會被擋。這是 bucket 層級 allowedMimeTypes 之外的第二道防線,且判斷邏輯更靈活(可依 bucket、依使用者角色動態調整)。
五、進階:結合資料表查角色(只有管理員能上傳)
Storage RLS 政策裡可以 select 其他資料表——這讓「依角色授權」變得可能。假設你有一張 public.profiles 表記錄使用者角色,要做「只有 admin 能上傳到 brand-assets」:
-- 只有 profiles.role = 'admin' 的使用者能上傳品牌素材
create policy "Only admins can upload brand assets"
on storage.objects
for insert
to authenticated
with check (
bucket_id = 'brand-assets'
and exists (
select 1
from public.profiles
where profiles.id = (select auth.uid())
and profiles.role = 'admin'
)
);
exists (...) 子查詢會去 profiles 表確認「目前登入者是不是 admin」,是才放行。這種模式把 Storage 權限和你既有的角色系統接起來,比純路徑判斷更適合「後台上傳、公開展示」的內容管理場景。
六、除錯:檢視與測試政策
設完政策,你會想確認它們真的生效。以下 SQL 幫你檢視與模擬測試:
-- 檢視 storage.objects 上目前所有政策
select policyname, cmd, roles, qual, with_check
from pg_policies
where schemaname = 'storage' and tablename = 'objects';
-- 以特定使用者身分模擬測試 SELECT 政策是否如預期
set local role authenticated;
set local request.jwt.claims to '{"sub": "8f3a-1b2c", "role": "authenticated"}';
-- 這時查詢會套用 RLS:只應看到 8f3a-1b2c 資料夾下的檔案
select name from storage.objects where bucket_id = 'documents';
reset role;
set local request.jwt.claims 讓你在 SQL 層「假扮」某個登入者,直接驗證政策行為,不必真的跑一輪前端登入上傳——這是排查 Storage 權限問題最有效的手段。
常見錯誤與最佳實踐
坑一:建了 bucket 卻沒設任何政策,結果「全鎖」。
Storage 是 block by default——沒有政策 = 所有操作被擋。很多人以為「沒設政策 = 全開」,於是上傳時收到 new row violates row-level security policy 一頭霧水。正確做法:把「設政策」當成建 private bucket 的必要步驟。至少要為你想放行的每種操作(通常是 INSERT + SELECT,可能還有 UPDATE/DELETE)各寫一條。記住:Storage 的預設是「全關」,不是「全開」。
坑二:把私密檔案放進 public bucket。
public bucket 對「讀取」繞過政策——意思是任何人只要猜到或拿到路徑,就能用公開網址直接打開檔案,你設再多 SELECT 政策也沒用(public 根本不看 SELECT 政策)。曾有團隊把使用者上傳的身分證掃描件放進 public bucket,等於全網公開。正確做法:判斷準則只有一句——「這個檔案洩漏出去會不會有問題?」會,就放 private bucket,用 createSignedUrl 限時發放;不會(Logo、行銷圖)才放 public。private 不代表沒人能讀,而是「讀取受政策與 signed URL 控管」。
坑三:upsert(換頭像/覆寫)失敗,因為少了 UPDATE 或 SELECT 政策。
「覆寫既有檔案」在 Postgres 層需要 SELECT + INSERT + UPDATE 三種權限,但很多人只寫了 INSERT,於是第一次上傳成功、第二次 upsert: true 卻報 RLS 錯誤。正確做法:要支援覆寫,就把 INSERT、SELECT、UPDATE 三條政策都補齊(授權條件用同一套 bucket_id + 路徑第一層 判斷),且 UPDATE 政策記得同時寫 using 和 with check。
坑四:路徑函式與型別用錯。
兩個高頻小錯:一是把 (storage.foldername(name))[1] 寫成 [0]——Postgres 陣列從 1 開始,[0] 會回傳 NULL,導致政策永遠不成立、看似「明明對了卻一直被擋」。二是漏了 ::text 轉型——auth.uid() 是 uuid、路徑是 text,直接比較會型別錯誤。正確做法:牢記 (storage.foldername(name))[1] = (select auth.uid()::text) 這一整串的寫法,[1] 與 ::text 缺一不可。
坑五:想用 owner_id 欄位判斷擁有者。
storage.objects 有個 owner_id 欄位,看起來很適合拿來判斷擁有者,但它在某些上傳路徑(如 signed upload URL、S3 API)下可能是 NULL,用它做政策會時靈時不靈。正確做法:一律用「路徑第一層資料夾」判斷擁有者,也就是 (storage.foldername(name))[1] = (select auth.uid()::text)。這也是為什麼路徑慣例要嚴格採 user_id/檔名——它讓授權判斷有一個穩定、可靠的依據。
最佳實踐總結:
- block by default 要牢記:private bucket 建好後,主動為每種操作寫政策,否則全鎖
- 公開讀、私密寫是預設心法:public 只放寬讀取,寫入永遠要靠 RLS 政策
- 擁有者判斷用路徑第一層,不用
owner_id:(storage.foldername(name))[1] = (select auth.uid()::text)才穩定 - 覆寫要補齊 INSERT + SELECT + UPDATE:UPDATE 政策記得同時寫
using與with check - 敏感檔案一律 private + 短效 signed URL:signed URL 無法主動撤銷,效期設短一點
- 善用
pg_policies與set local request.jwt.claims除錯:在 SQL 層假扮使用者驗證政策,最省時
小結
這是 Supabase 系列教學的第 025 篇,接續 Storage 段。上一篇《Storage 物件儲存入門》我們學會了建 bucket、用 upload 上傳、用 getPublicUrl/createSignedUrl 取用、用 list/remove 管理,並埋下伏筆——路徑要採 user_id/檔名 慣例,替權限鋪路。這一篇,我們就把那條伏筆兌現:把你在 009–011 篇熟透的 RLS,下沉到 storage.objects 資料表,讓每個檔案的存取都精準受控。
核心觀念濃縮成一句話:Storage 的權限就是 RLS——政策全寫在 storage.objects 上,依 bucket_id(哪個 bucket)與 (storage.foldername(name))[1] = (select auth.uid()::text)(路徑第一層 = 自己 user_id)授權;預設 block by default(全鎖),public 只放寬讀取、寫入永遠受政策管,敏感檔案放 private 用 signed URL 限時發放。
- 政策位置:所有 Storage RLS 政策寫在
storage.objects,依 INSERT/SELECT/UPDATE/DELETE 分開設 - 路徑授權:
(storage.foldername(name))[1] = (select auth.uid()::text)實現「每人只能碰自己資料夾」 - 常見模式:每人自掃門前雪、公開讀私密寫、依副檔名限制、結合
profiles查角色 - public 與 signed URL:public 只繞過讀取政策,signed URL 是 private bucket 對外的限時授權出口
- 避坑:沒設政策全鎖、私密檔勿放 public、覆寫補齊三種權限、擁有者判斷用路徑而非
owner_id
到這裡,你已經能把 Storage 的存取控制做到「誰能讀、誰能寫、誰只能碰自己的檔案」這種細粒度了——檔案的「安全」這一塊算是守住了。接下來要談的是檔案的「效能與規模」:如何即時產生縮圖、如何用既有的 S3 工具操作 Supabase Storage。下一篇《Image Transformations 與 S3》,我們就聊怎麼讓圖片載入更快、怎麼把 Storage 接進你熟悉的 S3 生態。權限守好了,接著讓它跑得又快又通用。下一篇見。