Supabase Auth 入門:使用者、Session 與 JWT | Supabase 完整教學

2026/10/08
Supabase Auth 入門:使用者、Session 與 JWT | Supabase 完整教學

Supabase Auth 建立在 GoTrue 這套開源驗證伺服器之上,負責「你是誰」這件事——使用者的註冊、登入、以及登入狀態(session)的管理。這一篇是核心服務的起點:我們會講清楚 access token(JWT)與 refresh token 怎麼搭配、auth.users 系統表與你自建的 public.profiles 如何關聯,以及最關鍵的一環——JWT 裡的 sub 為什麼等於 auth.uid(),讓 Auth 與前面學過的 RLS 無縫接上。

前言

一句話定義本篇主題:Supabase Auth 是建立在 GoTrue(Netlify 開源、Supabase 大幅強化的 Go 驗證微服務)之上的認證系統,它負責驗證使用者身份、簽發代表登入狀態的 JWT,並把所有使用者資料存在 PostgreSQL 的 auth schema,再透過 JWT 的 sub claim 與 Row Level Security(RLS)銜接,構成一套「認證 + 授權」的完整架構。

在進入細節前,先把兩個常被混用的詞分清楚。認證(Authentication) 回答的是「你是誰」——驗證這個人確實是他宣稱的那個帳號;授權(Authorization) 回答的是「你能做什麼」——決定這個已知身份的人可以存取哪些資料。Supabase Auth 負責前者,而後者,我們在系列的 009~011 篇已經學過,是由 PostgreSQL 的 RLS 負責的。這一篇最重要的價值,就是把這兩塊接起來。

用一個類比:Supabase Auth 就像大樓的門禁系統。 你在櫃台出示證件登記(登入),系統核對後發給你一張「訪客卡」(access token / JWT),這張卡上印著你的身份編號、有效期限(預設 1 小時)。你進出各樓層的門(存取資料)時,刷的就是這張卡,門上的讀卡機(RLS)看卡上的身份編號決定放不放行。卡快過期時,你不用回櫃台重新登記,只要用一張「續卡券」(refresh token)就能換一張新卡。整套機制的精妙之處在於:門禁(Auth)和每一道門的權限規則(RLS)是分開的,但靠卡上那個「身份編號」串在一起。

本篇你會學到:

  • Session 的組成:access token(JWT,短期)與 refresh token(長期、單次使用)如何協作
  • JWT 是什麼:payload 裡的 sub、role、aud、exp 等關鍵 claims 代表什麼
  • 資料表關聯:auth.users 系統表為何不該直接用,改用 public.profiles 一對一關聯的正確設計
  • Auth ↔ RLS 整合:JWT 的 sub = auth.uid(),認證與授權如何靠這一個欄位串起來
  • 讀取狀態的正確姿勢:getUser()(向伺服器重新驗證)與 getSession()(讀本地)的關鍵差異

核心概念

理解 Supabase Auth,先抓住一條主線:登入產生 session → session 的核心是 JWT → JWT 的 sub 讓 RLS 認得你。把這條線走通,後面的實作就都是它的具體展開。

一、Session 由兩個 token 組成

當使用者成功登入,Supabase Auth 不是只發一個 token,而是發一組(token pair):

元件存活時間角色
Access Token(JWT)短期,預設 1 小時每次請求都帶著它,向資料庫證明「我是誰」
Refresh Token長期、單次使用access token 過期前,用它換一組新的 token pair

為什麼要拆成兩個?因為安全與便利的權衡。access token 攜帶身份資訊、會被每個請求帶著到處跑,所以刻意設計成短命——就算不小心洩漏,攻擊者也只有很短的時間可用。但如果 token 一過期就要使用者重新輸入密碼,體驗會很糟,於是有了長效的 refresh token:它不隨一般請求送出,只在需要「續期」時登場,悄悄地在背景把過期的 access token 換新。supabase-js 預設會自動幫你做這件事(autoRefreshToken),所以前端通常感覺不到 token 過期。

這個機制還有一道防盜保險:Refresh Token Reuse Detection。refresh token 是「單次使用」的——用一次就作廢、換一張新的。如果同一張 refresh token 被使用超過一次(除了 10 秒的容忍窗口),Supabase 會判定可能被盜用,直接把整個 session 強制終止,逼所有相關 token 全部失效。

二、JWT 裡到底裝了什麼

Access token 是一個標準的 JWT(JSON Web Token)。它的 payload 是一段 JSON,裝著這次登入的身份資訊,重要的 claims 如下:

Claim意義
sub使用者的 UUID,對應 auth.users.id——本篇的主角
audauthenticated(已登入)或 anon(未登入匿名)
role對應的 PostgreSQL 角色(authenticated / anon)
exp過期時間(Unix timestamp),預設簽發後 1 小時
email使用者 email
aal驗證保證等級:aal1(一般)或 aal2(已完成 MFA)

其中 sub(subject 的縮寫)是整套整合的樞紐——它就是這個使用者在 auth.users 表的 id。記住這一點,第四小節會用到。

三、auth.users 與 public.profiles:為什麼要分兩張表

所有使用者的核心資料,GoTrue 都存在 auth schema 底下:

auth.users          -- 使用者帳號(id、email、加密密碼、metadata…)
auth.sessions       -- 工作階段
auth.identities     -- 使用者身份(支援多個 OAuth provider)
auth.refresh_tokens -- refresh token

auth.users 是 GoTrue 自己管理的系統表,你不該直接拿它當作應用的使用者資料表,原因有二:

  1. 它不對外暴露:auth schema 不會透過 Supabase 的自動 API(PostgREST)開放,前端 supabase-js 查不到它——這是安全設計,因為裡面有密碼雜湊等敏感欄位。
  2. 它的結構不由你掌控:欄位由 GoTrue 定義,隨版本可能變動,你直接往裡加欄位或依賴其結構,未來升級可能出問題。

正確做法:在 public schema 建一張自己的 profiles 表,用外鍵 references auth.users(id) 把兩者一對一綁起來。auth.users 專責「驗證身份」,profiles 專責存放應用層的使用者資料(暱稱、頭像、簡介…),並在 profiles 上啟用 RLS。

   auth(Supabase 管理)              public(你管理)
 ┌─────────────────────┐        ┌──────────────────────┐
 │ auth.users          │        │ public.profiles      │
 │  id (uuid) ─────────┼───────▶│  id (uuid, FK)       │
 │  email              │  1 : 1 │  username            │
 │  encrypted_password │        │  avatar_url          │
 └─────────────────────┘        │  (啟用 RLS)         │
                                 └──────────────────────┘

四、Auth ↔ RLS 的整合點:sub = auth.uid()

這是本篇最該記住的一句話。回顧一下 009~011 篇的 RLS:policy 裡我們常寫 (select auth.uid()) = user_id,用 auth.uid() 取得「當前使用者的 UUID」。那 auth.uid() 的值是哪來的?

答案就是 JWT 的 sub。整條鏈是這樣的:

使用者登入
   │
   ▼  Supabase 簽發 JWT,payload.sub = 該使用者的 auth.users.id
access token(JWT)
   │
   ▼  前端每次請求都帶著這個 JWT
PostgreSQL 收到請求,解出 JWT
   │
   ▼  RLS policy 呼叫 auth.uid(),回傳的正是 JWT 的 sub
auth.uid() === JWT.sub === 該使用者的 UUID

所以當你寫下 using ((select auth.uid()) = user_id),它的真正意思是:「這一列的 user_id,要等於『當前這張 JWT 所代表的那個使用者』,才放行。」認證由 Auth 透過 JWT 的 sub 告訴資料庫「你是誰」,授權由 RLS 依據 auth.uid() 決定「你能碰哪些列」——兩者靠 sub 這一個欄位無縫串起。這就是為什麼 Supabase 能把驗證和資料庫層的授權整合得如此自然。

五、支援哪些登入方式(本篇只概覽)

Supabase Auth 支援多種登入方式,本篇只先列出全貌,各方式的細節留給後續文章:

  • Email / Password:最傳統的帳號密碼,搭配 email 確認流程(下一篇主題)
  • OAuth 社群登入:Google、GitHub、Apple 等 19+ 個 provider
  • Magic Link / OTP:無密碼的一次性 email 連結或驗證碼
  • Phone / SMS OTP:以手機號碼 + 簡訊驗證碼登入
  • MFA / TOTP:在第一因子之上再加一層多重要素驗證

無論用哪一種方式登入,最終產物都一樣:一組 access token(JWT)+ refresh token 的 session。所以只要你搞懂了本篇的 session / JWT / RLS 整合,後面每一種登入方式都只是「不同的入口,同樣的出口」。

實作範例

以下範例可在 Supabase 專案的 SQL Editor 與前端專案直接使用。我們會從最重要的 profiles 關聯設計開始,一路做到觀察真實的 JWT。

一、建立 profiles 表並關聯 auth.users

在 SQL Editor 執行以下 SQL,建立一張與 auth.users 一對一關聯的 profiles 表,並啟用 RLS:

-- 建立 public.profiles,用外鍵關聯 auth.users
create table public.profiles (
  id uuid not null references auth.users (id) on delete cascade,
  username text unique,
  full_name text,
  avatar_url text,
  updated_at timestamptz default now(),
  primary key (id)  -- 主鍵即 auth.users.id,天然形成一對一
);

-- 啟用 RLS(不啟用的話預設拒絕所有存取;啟用後靠 policy 開放)
alter table public.profiles enable row level security;

-- 所有人都能讀 profile(公開個人頁常見需求)
create policy "公開 profile 對所有人可見"
  on public.profiles for select
  using (true);

-- 使用者只能更新「自己的」profile:id 要等於當前使用者
create policy "使用者只能更新自己的 profile"
  on public.profiles for update
  to authenticated
  using ((select auth.uid()) = id)
  with check ((select auth.uid()) = id);

注意最後那條 policy 的 (select auth.uid()) = id——這正是第四小節講的整合點:auth.uid() 取出當前 JWT 的 sub,拿去比對這一列 profiles 的 id。因為 profiles.id 就是 auth.users.id,這條規則的意思就是「你只能改自己那一列」。

小提醒:on delete cascade 讓使用者從 auth.users 被刪除時,對應的 profiles 列自動一起刪掉,避免留下孤兒資料。

二、註冊時自動建立 profile(trigger)

有了 profiles 表,還缺一步:使用者一註冊(在 auth.users 新增一列),要自動在 profiles 幫他補上對應的一列,否則他就有帳號卻沒個人資料。這用一個 trigger 解決:

-- 這個函式會在新使用者建立時被觸發
create or replace function public.handle_new_user()
returns trigger
language plpgsql
security definer set search_path = ''  -- security definer 才有權寫入 public.profiles
as $$
begin
  insert into public.profiles (id, full_name, avatar_url)
  values (
    new.id,                                      -- new 就是剛插入 auth.users 的那一列
    new.raw_user_meta_data ->> 'full_name',      -- 從註冊時帶入的 metadata 取值
    new.raw_user_meta_data ->> 'avatar_url'
  );
  return new;
end;
$$;

-- 綁定 trigger:auth.users 每新增一列,就執行上面的函式
create trigger on_auth_user_created
  after insert on auth.users
  for each row execute procedure public.handle_new_user();

這裡的 new.raw_user_meta_data 是使用者註冊時透過 options.data 帶進來的資料(例如 full_name)。security definer 讓這個函式以擁有者權限執行,才有辦法寫入 public.profiles。從此之後,任何管道(email、OAuth、Magic Link…)註冊的新使用者,都會自動擁有一筆 profile。

三、前端:註冊、登入與讀取使用者

前端用 supabase-js。以下示範最基礎的註冊與登入(各方式細節留待後續,這裡只讓你看到 session 是怎麼產生的):

import { createClient } from '@supabase/supabase-js'

const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)

// 註冊:options.data 會進 raw_user_meta_data,被上面的 trigger 取用
const { data: signUpData, error: signUpError } = await supabase.auth.signUp({
  email: 'user@example.com',
  password: 'a-strong-password-123',
  options: { data: { full_name: 'Ben Chang' } },
})

// 登入:成功後 data.session 就是那組 { access_token, refresh_token }
const { data, error } = await supabase.auth.signInWithPassword({
  email: 'user@example.com',
  password: 'a-strong-password-123',
})

if (!error) {
  console.log('登入成功:', data.user.email)
  // data.session.access_token 就是那張 JWT
  // supabase-js 會自動保存 session 並在過期前用 refresh token 續期
}

四、getUser() vs getSession():讀狀態的兩種方式

登入後,你需要在別處讀取「當前使用者是誰」。supabase-js 提供兩個方法,看起來很像,但用途天差地遠:

// getSession():直接讀「本地儲存」的 session,不向伺服器重新驗證。快,但不可信任。
const { data: { session } } = await supabase.auth.getSession()
// session?.user 來自本地——適合純前端顯示 UI(例如要不要顯示登入按鈕)

// getUser():帶著 access token 去「向 Supabase Auth 伺服器」重新驗證身份。
const { data: { user }, error } = await supabase.auth.getUser()
// user 是伺服器確認過的——任何「授權判斷」都必須用它
if (user) {
  console.log('已驗證的使用者 id:', user.id)  // 這個 id 就是 JWT 的 sub
}

關鍵差異一句話:getSession() 相信本地存的東西,getUser() 相信伺服器。在伺服器端程式碼(Server Component、API route、middleware)要決定「這個請求能不能存取某筆資料」時,一律用 getUser()——因為客戶端送來的 session 是可以被偽造的,只有讓伺服器重新驗證過的身份才可信。

五、觀察真實的 JWT

想親眼看看 JWT 裡的 sub?把登入後拿到的 access token 印出來,貼到 jwt.io 解碼,或在瀏覽器 console 手動解 payload:

const { data: { session } } = await supabase.auth.getSession()
const token = session?.access_token

if (token) {
  // JWT 由 header.payload.signature 三段組成,取中間那段 base64 解碼
  const payload = JSON.parse(atob(token.split('.')[1]))
  console.log(payload.sub)   // 使用者 UUID —— 這就是 auth.uid() 的值
  console.log(payload.role)  // 'authenticated'
  console.log(payload.aud)   // 'authenticated'
  console.log(payload.exp)   // 過期時間(Unix timestamp)
}

你會看到 payload.sub 是一串 UUID——它與你在資料庫 select auth.uid() 拿到的值、以及 auth.users.id,完全是同一個。這就是本篇反覆強調的整合樞紐,眼見為憑。

常見錯誤與最佳實踐

坑一:用 getSession() 做授權判斷。 這是最危險也最常見的錯。有人在伺服器端寫 const { data: { session } } = await supabase.auth.getSession(),然後拿 session.user.id 去決定「這個人能不能刪這篇文章」——問題是 getSession() 只讀本地 cookie,完全不向伺服器驗證,而 cookie 在伺服器端是可以被偽造的。等於讓人自己填一張身份證就放行。正確做法:任何牽涉權限的決策,一律用 getUser(),讓 Supabase 伺服器重新驗證 token 的真偽與有效性。getSession() 只適合純客戶端的 UI 顯示(例如判斷要不要顯示「登入」按鈕)。

坑二:直接把 auth.users 當應用資料表用。 想加個「暱稱」「個人簡介」欄位,就直接往 auth.users 塞,或試圖從前端查 auth.users——結果不是查不到(它不透過 PostgREST 暴露),就是埋下升級隱患(它的結構由 GoTrue 掌控)。正確做法:建 public.profiles 表,用 id uuid references auth.users(id) 一對一關聯,應用資料一律放 profiles,auth.users 只留給 GoTrue 管身份。

坑三:建了 profiles 表,卻忘了自動建 profile。 profiles 表建好了,卻沒設 trigger——於是使用者註冊後,auth.users 有他、profiles 卻沒有他那一列。之後任何 join profiles 或讀他的頭像,全都拿到 null,甚至報錯。正確做法:用 handle_new_user() 函式 + on_auth_user_created trigger,讓每個新使用者自動獲得一筆 profile,確保兩張表永遠同步。

坑四:以為登入方式不同,session 的處理就不同。 初學者常覺得「OAuth 登入」「Email 登入」「Magic Link」是完全不同的三套東西,於是重複寫三套狀態管理邏輯。正確做法:認清無論哪種登入方式,產物都是同一組 access token + refresh token 的 session——你的狀態管理、getUser() 驗證、RLS 整合,全都共用同一套。入口不同,出口一致。

最佳實踐總結:

  • 授權一律 getUser():只有伺服器驗證過的身份才可信,getSession() 只做 UI
  • 應用資料放 profiles:auth.users 留給 GoTrue,用外鍵一對一關聯
  • 註冊自動建 profile:用 trigger 讓兩張表永遠同步,別留孤兒帳號
  • 記住整合樞紐:JWT 的 sub = auth.uid() = auth.users.id,Auth 與 RLS 靠它串起
  • 理解 session 本質:短命 JWT + 長效 refresh token,續期由 supabase-js 自動處理

小結

這是 Supabase 系列教學的第 019 篇,也是「SB-3 核心服務」的開篇。上一篇《型別生成與進階用法》我們把整個「SB-2 存取層」收尾——讓每個 supabase-js 查詢都型別安全。從這一篇起,我們正式踏入幾乎每個應用都少不了的 Auth。

核心觀念濃縮成一句話:Supabase Auth 建立在 GoTrue 之上,負責認證;登入產生一組 session——短命的 access token(JWT)+ 長效單次使用的 refresh token;JWT 的 sub claim 就是使用者的 UUID,等於資料庫裡的 auth.uid(),這讓 Auth 與 RLS(009~011 篇)無縫整合;使用者資料放在你自建的 public.profiles(外鍵關聯 auth.users),而讀取登入狀態時,授權判斷一律用會向伺服器重新驗證的 getUser(),別用只讀本地的 getSession()。

  • Session = JWT + refresh token:短命 access token 保安全,長效 refresh token 保便利
  • sub = auth.uid():認證與授權靠這一個欄位串起,是整套整合的樞紐
  • auth.users vs profiles:系統表管身份、自建表放資料,一對一外鍵關聯
  • getUser() 才做授權:向伺服器驗證才可信,getSession() 只讀本地做 UI
  • 登入方式不同、session 一致:後面每種登入方式都是「同一個出口」

打好了這塊地基,接下來就能逐一攻克各種登入方式。下一篇《Email/Password 驗證》,我們從最經典的帳號密碼登入開始,實作完整的註冊、email 確認、登入與登出流程,把本篇的 session 概念落地成一套能跑的驗證系統。下一篇見。

BenZ Software Developer

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

本週主打

AI 自動化入門包

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

看看這個產品 →