nuxt-better-auth 認證整合

把認證從 Supabase 拆出來交給 Better Auth,換到的是完全自訂的使用者模型, 付出的是資料庫再也拿不到登入身分——這個代價會一路影響到 RLS 怎麼寫。

系列・Nuxt 4 全棧實戰筆記・第 4/12 篇

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 社交登入的系統上等於門戶洞開,因為註冊不需要任何內部審核。

這個決定屬於授權而不是認證,下一篇會展開;但它必須在建使用者表的那一刻就決定,因為預設值一旦錯了,錯的是已經寫進資料庫的每一列。