Supabase 自己就有 Auth,而我們把認證拆出來交給 @onmax/nuxt-better-auth,只用 Supabase 當資料庫。
這不是「哪個比較好」的比較,是一個有明確代價的交換。這篇先講代價,再講實際踩到的兩個坑。
拆開之後拿到什麼
拿到的主要是使用者模型的自由度。認證走 Supabase Auth 時,使用者的正本在 auth.users,你要加的欄位得靠另一張表關聯回去,而那張表與 auth.users 的生命週期並不是你在管。認證拆出來之後,使用者就是你自己資料表裡的一列,要加什麼欄位、什麼時候建立、怎麼刪,都是你的決定。
其次是 session 的形態:標準的 HTTP-only cookie,伺服器端一行 requireUserSession(event) 就拿得到,不需要在前後端之間傳遞 token。
付出什麼:資料庫不再知道你是誰
這是整個選擇裡最重要、也最容易到後期才發現的一件事。
Supabase 的 RLS 之所以能寫 auth.uid() = id 這種 policy,是因為瀏覽器帶著的是 Supabase 簽發的 JWT,PostgREST 從裡面解出使用者身分。認證改由別的系統負責之後,瀏覽器端的 Supabase client 手上沒有那張 JWT——auth.uid() 在 RLS 裡會是空的。
後果比「少了一個方便的寫法」嚴重得多:你不能再寫「使用者只能看到自己的資料」這種 policy 並期待它認得人。 RLS 仍然會執行,但它唯一能分辨的是「有沒有帶那把 anon key」——而 anon key 是隨前端一起送到每個訪客瀏覽器裡的公開字串。
所以在這個架構下,「anon key 讀得到」等於「任何人讀得到」。凡是需要看人給資料的讀取,同樣得走你自己的伺服器端,不能只靠 RLS 擋。瀏覽器端的 Supabase client 只適合真正的公開資料。
所以資料存取的分工必須跟著調整——所有需要「誰能做什麼」的判斷,都得在你自己的伺服器端做完,再用具備完整權限的 client 去操作資料庫。這正是上線前必須先定案的三個決定裡「讀 Client,寫 Server」的來源:它不只是安全偏好,在這個架構下它是唯一可行的形狀。
你可能會想:兩套並存呢?登入走 Better Auth,同時也在 Supabase 那邊建一個對應的使用者、簽一張 JWT 給瀏覽器用。技術上做得到,但你等於承諾維護兩份使用者的正本,並且要讓它們在建立、停用、刪除、改角色這四個時機保持同步。少同步一次,資料庫看到的身分就跟應用程式看到的不一樣——而這種不一致的症狀是「某個人的權限時對時不對」,是所有 bug 裡最難查的一類。
看清楚這個代價再決定,比事後才發現划算得多。反過來說,如果你的應用高度依賴 per-user 的 RLS policy,那就不該把認證拆出去。
設定的形狀
伺服器端集中在一個設定檔,OAuth provider 與 session 存活時間都在這裡:
// server/auth.config.ts
import { defineServerAuth } from '@onmax/nuxt-better-auth/config'
export default defineServerAuth({
emailAndPassword: { enabled: true },
socialProviders: {
google: {
clientId: process.env.NUXT_OAUTH_GOOGLE_CLIENT_ID,
clientSecret: process.env.NUXT_OAUTH_GOOGLE_CLIENT_SECRET,
},
},
session: {
expiresIn: 60 * 60 * 24 * 7, // 7 天
updateAge: 60 * 60 * 24, // 每 24 小時滾動更新
},
})
使用者型別要自己擴充一次,之後 user.role 在整個專案都有型別提示——少了這一步,角色比對就退化成字串比對:
// server/types/auth.d.ts
declare module '@onmax/nuxt-better-auth' {
interface User {
id: string
email: string
name?: string
role: 'admin' | 'manager' | 'staff' | 'unauthorized'
}
}
伺服器端要求登入是一行,而且它保證回來的 user 一定存在——不存在時它自己回 401:
export default defineEventHandler(async (event) => {
const { user } = await requireUserSession(event)
return { message: `Hello, ${user.name}` }
})
兩個實際踩到的坑
登入成功,重新整理就沒了。 症狀看起來像 session 沒建立,實際上多半是 cookie 的存活設定與更新機制沒配好——expiresIn 決定 session 多久失效,updateAge 決定它多久滾動一次。只設前者、沒設後者的話,使用者會在固定時間點被整批登出,而且時機看起來毫無規律(因為那是各自的簽發時間)。
OAuth 導回錯的地方。 callback URL 必須在 provider 那端逐一登記,而且本地與正式環境是兩條不同的網址:
本地:http://localhost:3000/api/auth/callback/google
正式:https://your-domain.com/api/auth/callback/google
漏了正式環境那條,本地測試會全部通過,上線第一個用 Google 登入的人才會發現。這類錯誤沒辦法靠程式碼檢查擋掉,只能列進上線前的確認項。
一個容易漏的預設值
新註冊的使用者要給什麼角色?
預設值應該是沒有權限(我們用 unauthorized),由管理員手動升級。如果預設給了 staff,那任何人只要註冊就自動成為員工——這在有 OAuth 社交登入的系統上等於門戶洞開,因為註冊不需要任何內部審核。
這個決定屬於授權而不是認證,下一篇會展開;但它必須在建使用者表的那一刻就決定,因為預設值一旦錯了,錯的是已經寫進資料庫的每一列。