Supabase 讓你在幾分鐘內就有一個能用的後端。代價是很多安全性相關的預設值都偏向方便,而且偏向方便的那一邊不會報錯。
這篇是三個必須在上線前定案的決定。它們表面上互不相干,但做錯的後果是同一種形狀:沒有錯誤訊息,只有影響 0 列、空陣列,或看得到不該看的資料。
一、Schema 只能從 migration 進來
第一個決定:資料庫的結構變更,允不允許從 Studio 的圖形介面進去。
答案是不允許——但理由不是「手動操作不專業」,而是圖形介面改出來的東西在你的版本庫裡不存在。
它造成的不是立即的錯誤,而是一組會延後發作的症狀:
- 本地跑一次重建(
supabase db reset)之後,那個欄位就消失了 - 其他人的環境跟你不一樣,而且雙方都覺得自己是對的
- 產生出來的 TypeScript 型別跟正式環境的實際結構對不上
所以流程只有一條:本地建 migration → 本地重建驗證 → lint → 重新產生型別 → 進版本庫 → CI 推上去。
supabase migration new add_users_table
# 編輯 supabase/migrations/<timestamp>_add_users_table.sql
supabase db reset # 從零重建,確認 migration 真的能跑
supabase db lint --level warning # 必須零警告
supabase gen types typescript --local | tee app/types/database.types.ts > /dev/null
圖形介面不是完全不能用——查資料、看 policy 都很方便。真的用它做了結構變更,就把變更倒出來變成 migration,別讓它留在只有你知道的地方:
supabase db diff --use-migra -f from_gui
兩個實際踩到的坑
Sequence 不會跟著匯入的資料走。 用 INSERT ... (id, ...) 明確指定 id 匯入一批資料之後,下一筆新增會撞 duplicate key——因為 sequence 還停在匯入前的位置。它不會自己校正:
select setval(
'public.users_id_seq',
(select coalesce(max(id), 0) + 1 from public.users),
false
);
遠端的 migration 記錄可能跟本地對不上。 這種狀態下 supabase db push 會直接停住。先看清楚兩邊的差異,再決定要標記哪一筆:
supabase migration list --linked
supabase migration repair --status reverted <timestamp>
repair 是改記帳、不是改資料——它只是告訴遠端「這筆當作沒發生」。動它之前要確認那筆 migration 的實際效果在遠端是什麼狀態,否則你會得到一個記帳正確、結構錯誤的資料庫。
二、讀走 Client,寫走 Server
第二個決定:瀏覽器端的 Supabase client 可以做到什麼程度。
我們的答案是只讀。查詢直接從瀏覽器打,靠 RLS 保護;所有新增、修改、刪除一律走自己的 Server API,在那裡做身分驗證、業務驗證與紀錄。
理由不是「Client 端寫入不安全」——RLS 寫對的話它是安全的。理由是寫入路徑上要做的事,遠不只權限檢查:業務規則驗證、稽核紀錄、跨表的一致性。這些散在瀏覽器端就沒有單一的把關點,而權限檢查是唯一能靠 RLS 補回來的那一項。
// Client:只查詢
const client = useSupabaseClient<Database>()
const { data } = await client.from('users').select('id, name')
// 寫入:走自己的 API,在 handler 內驗證身分與業務規則
await $fetch('/api/v1/users', { method: 'POST', body })
Server API 寫入時用哪個身分,會決定 RLS 還剩不剩作用:帶著使用者身分去寫,RLS 仍是最後一道防線;改用 service_role,RLS 就整個被繞過(它具有 BYPASSRLS),權限只剩 handler 裡自己寫的檢查。下面「Toast 說成功」這個坑,只會發生在前一種。
你可能會想:那不是每個寫入都要多刻一支 API,很麻煩嗎?是麻煩,但麻煩的部分不是你以為的那個。多寫的那幾行 handler 是固定成本,一次付完;真正貴的是另一條路——把權限規則寫成 RLS policy 之後,每次業務規則變動你都要改資料庫、重跑 migration、而且沒辦法在改之前先跑測試看它會不會擋掉現有使用者。權限邏輯放在應用層,至少它是可以被測試、可以被逐行讀懂的程式碼。
最常踩的一個坑:Toast 說成功,資料沒變
症狀是三步:API 回 200、前端跳出「儲存成功」、重新整理之後資料還是舊的。
根因在 policy 的覆蓋範圍不對稱:多數人先寫了 SELECT policy,UPDATE / DELETE 的 policy 漏了,或條件比 SELECT 窄。於是讀得到,寫入卻被 RLS 靜默過濾成影響 0 列。Postgres 對這個情況的行為是:USING 條件不符的列不會被更新,也不會報錯(只有 WITH CHECK 不符才會整句失敗)。資料庫沒有把它當錯誤,handler 沒有檢查受影響列數,就會照常回成功,前端當然顯示「儲存成功」。
-- 讀得到不代表改得到:UPDATE / DELETE 要各自有 policy
create policy "manager can update" on public.resources for update
using (public.current_user_role() in ('admin', 'manager'))
with check (public.current_user_role() in ('admin', 'manager'));
兩個相關的前提:UPDATE 要能讀到那一列,所以同一張表還必須有能涵蓋該列的 SELECT policy,沒有的話 UPDATE 同樣不會如預期運作。另外,不要只靠「沒有報錯」判斷寫入成功,handler 裡檢查受影響列數(例如 .select() 取回更新後的列,確認不是空陣列),0 列就當失敗處理。
順帶兩個會咬人的細節:
auth.uid()這類函式要寫成(select auth.uid())。沒包的話它會逐列重算;包起來 Postgres 會把它當成 initPlan,每個 statement 只算一次(Supabase 官方文件也建議這樣寫,前提是函式結果不隨列資料改變)。這在幾百列的表上看不出來,在幾十萬列上是量級差異。- policy 裡不要直接查表(
select role from user_roles where ...),封裝成stable的 helper 函式。理由同上,而且 policy 邏輯散在各表會漂。
三、函式與 View 的預設值都不安全
第三個決定:資料庫函式與 View 的安全性設定要不要強制。
要,而且是無例外的強制——因為這兩者的預設值都偏向不安全。
函式的 search_path 預設是可變的。 沒有釘死的話,函式裡寫的 users 到底解析到哪張表,取決於呼叫當下的 search_path。有能力建立 schema 的人可以放一張同名的表進去,讓你的函式讀到它。修法是釘死成空字串,並在 SQL 內一律用完整名稱:
create function public.get_user()
returns text
language sql
security definer
set search_path = ''
as $$
select name from public.users where id = auth.uid(); -- 一定要有 public.
$$;
這兩件事是綁在一起的:只設 search_path = '' 而 SQL 裡還寫 from users,函式會直接壞掉(relation "users" does not exist)。這個錯誤反而是好事——它會在第一次呼叫就爆,不像其他兩個坑要等到出事才知道。
View 預設用建立者的權限執行(security_invoker 選項需要 Postgres 15 以上),也就是說它會繞過呼叫者的 RLS。一張基於受保護資料表的 View,如果沒有明確宣告,普通使用者透過它就能看到全部資料:
create view public.user_summary
with (security_invoker = true) -- 沒有這行 = 繞過呼叫者的 RLS
as select id, name, email from public.users;
SECURITY DEFINER 與 SECURITY INVOKER 的選擇,判準是「這個函式要不要遵守呼叫者的 RLS」。一般業務邏輯用 INVOKER(遵守,較安全的預設);需要跨使用者存取的管理功能才用 DEFINER,而且用了就必須在函式內自己做權限檢查——因為你剛剛把 RLS 這層保護拿掉了。
這幾項不必靠自律,Supabase 的 Security Advisor(Dashboard 的 Advisors)有對應的檢查:function_search_path_mutable(函式的 search_path 沒釘死)、security_definer_view(View 以建立者權限執行)、rls_disabled_in_public(public schema 的表沒開 RLS)。上線前把這些警告清到零。
要小心別把另一個指令當成這道檢查。CLI 的 supabase db lint 跑的是 plpgsql_check,檢查的是 PL/pgSQL 函式本身的錯誤(型別、未使用變數、缺 RETURN、EXECUTE 注入等),不會報上面這三項。它仍值得放進提交前檢查,但兩者看的是不同的東西:
supabase db lint --level warning # 函式內容的錯誤,不是 Advisor 的安全檢查
這三個決定的共同形狀
| 決定 | 預設行為 | 做錯時的症狀 |
|---|---|---|
| Schema 從哪裡進來 | 圖形介面可以直接改 | 重建後欄位消失、環境彼此不一致 |
| 誰能寫入 | Client 端可以寫 | 影響 0 列,但 API 回 200 |
| 函式 / View 安全性 | search_path 可變、View 繞過 RLS | 看得到不該看的資料 |
三者的預設值都選了方便,而且做錯的當下都不會有錯誤訊息。這正是它們必須在上線前定案的原因:一旦有了正式資料,這三種靜默失敗都會先被當成別的問題查——查前端、查快取、查網路——而真正的原因在資料庫的設定裡,安靜地正常運作著。