Supabase Email/Password 驗證:註冊、確認信與密碼重設 | Supabase 完整教學
上一篇《Auth 入門》我們把 session、JWT 與 RLS 的整合地基打好了。這一篇要把它落地:實作最經典、幾乎每個應用都會用到的 Email / Password 驗證。我們會走完整條路——
signUp註冊、Email 確認信流程、signInWithPassword登入、signOut登出,一直到resetPasswordForEmail+updateUser的密碼重設,並用onAuthStateChange即時監聽狀態變化。你會得到一套真的能跑的驗證系統。
前言
一句話定義本篇主題:Email/Password 驗證是 Supabase Auth 最基礎的登入方式——使用者用 email 加密碼註冊 signUp、(若開啟確認)點擊確認信啟用帳號、用 signInWithPassword 登入、signOut 登出,忘記密碼時用 resetPasswordForEmail 收信、再用 updateUser 寫入新密碼,整個過程中的狀態變化都能靠 onAuthStateChange 被動監聽。
上一篇我們反覆強調過一句話:無論用哪種登入方式,最終產物都是同一組 session(access token + refresh token)。這一篇就是那句話的第一個具體例子。你在這裡學到的 session 處理、getUser() 驗證、RLS 整合,之後的 OAuth、Magic Link 全都共用——所以把 Email/Password 這條路走扎實,等於替後面所有登入方式打了底。
用一個類比幫你抓住全貌:Email/Password 驗證就像辦一張健身房會員卡。 你先填表登記(signUp),櫃台寄一封 email 請你點連結確認「這信箱真的是你的」(Email 確認);確認後卡片才生效。之後每次進場刷卡(signInWithPassword)就能進;離場刷退(signOut)。哪天忘了密碼(會員密碼),你按「忘記密碼」,櫃台寄一封重設連結(resetPasswordForEmail)到你信箱,你點進去設定新密碼(updateUser)。而 onAuthStateChange 就像門口的電子看板,你一刷卡進場、一刷退出場,看板立刻更新顯示——你不用一直去問櫃台「我現在是不是在裡面」。
本篇你會學到:
- 註冊與 Email 確認:
signUp的行為、confirm email開關如何決定「有沒有 session」、確認信怎麼流轉 - 登入與登出:
signInWithPassword的回傳、常見錯誤訊息、signOut的scope選項 - 密碼重設完整兩步:
resetPasswordForEmail(發信)→updateUser(寫新密碼),為什麼牽涉PASSWORD_RECOVERY - 狀態監聽:
onAuthStateChange的事件種類與正確用法(含unsubscribe) - 設定面:Email 樣板與 Redirect URL Allowlist——流程斷掉最常見的兇手
核心概念
Email/Password 的整個生命週期,可以濃縮成一條主線:註冊 → 確認信 → 登入 → session → 登出,而密碼重設是這條線之外的一條分支。先把這條線在腦中畫出來,實作就只是把每一段接上對應的 API。
一、confirm email 開關:決定「有沒有 session」的關鍵
在 Dashboard → Authentication → Providers → Email 有一個 Confirm email 開關。它是理解整條流程的第一顆鈕,因為它直接決定 signUp() 的行為:
| Confirm email | signUp() 之後 | 使用者狀態 |
|---|---|---|
| 開啟(建議正式環境) | 回傳 data.user,但 data.session 為 null;寄出確認信 | 未確認,尚不能登入 |
| 關閉(開發方便) | 直接回傳含 data.session 的結果 | 立即登入 |
換句話說:當 confirm email 開著,signUp() 不等於「登入」——它只是「建了一個待確認的帳號並寄信」。這是最多人卡住的地方:明明沒報錯,卻拿不到 session,還以為程式壞了。實際上你要做的是引導使用者去收信、點確認連結。
二、Email 確認信的流轉路徑
當 confirm email 開啟,signUp() 觸發後,整個確認流程是這樣走的:
使用者呼叫 signUp()
│
└─ Supabase 寄出確認信(連結含 token_hash 與 type=signup)
│
└─ 使用者在信箱點擊確認連結
│
└─ 導回你設定的 redirect URL(emailRedirectTo)
│
└─ Supabase 建立 session → 使用者正式登入
這裡有兩個設定必須到位,否則流程會斷:
- Email 樣板(Dashboard → Authentication → Email Templates):確認信樣板預設包含
{{ .ConfirmationURL }},這就是那條神奇連結。你可以改樣板的文案與樣式,但別把這個變數刪了。 - Redirect URL Allowlist(Dashboard → Authentication → URL Configuration):你在
emailRedirectTo指定的網址,必須先加進這份白名單,否則 Supabase 基於安全考量會拒絕導向,使用者點了連結卻回不到你的網站。
三、登入、登出與 session 的關係
一旦帳號確認完成,登入就單純了:signInWithPassword({ email, password }) 成功後回傳 data.session(那組 access token + refresh token)與 data.user。之後 supabase-js 會自動保存 session、並在 access token 過期前用 refresh token 續期——這些都是上一篇講過的機制,Email/Password 只是眾多入口之一。
登出用 signOut(),它有一個 scope 選項值得記住:
scope | 效果 |
|---|---|
'local'(預設) | 只登出當前這台裝置 / 這個瀏覽器 |
'global' | 撤銷此使用者所有裝置的 session |
'others' | 撤銷其他裝置,保留當前這台 |
四、密碼重設:為什麼是兩步
密碼重設是本篇最容易寫錯的部分,因為它是兩支 API、兩個頁面,中間還牽涉一個特殊的 session 狀態:
【頁面 A:忘記密碼頁】
resetPasswordForEmail(email, { redirectTo })
│
└─ Supabase 寄「重設密碼」信(連結指向 redirectTo)
│
└─ 使用者點信中連結
│
└─ Supabase 用連結 token 建立「恢復用」臨時 session
│ (onAuthStateChange 觸發 PASSWORD_RECOVERY)
▼
【頁面 B:重設密碼頁】
updateUser({ password: '新密碼' }) ← 真正寫入新密碼
關鍵理解:resetPasswordForEmail 本身不改密碼,它只做兩件事——發信、以及讓使用者點連結後進入一個「已驗證的恢復狀態」。真正改密碼的是 updateUser({ password }),而它之所以有權改,正是因為使用者此刻已因點信而處於已登入(恢復)狀態。所以流程斷掉最常見的原因,還是那個老問題:redirectTo 沒進 Allowlist。
五、onAuthStateChange:被動監聽狀態變化
前面的 API 都是你「主動呼叫」的。而 onAuthStateChange 相反——它是你註冊一次的監聽器,之後只要登入狀態變了,它就自動被呼叫,把最新 session 交給你。常見事件:
| 事件 | 觸發時機 |
|---|---|
SIGNED_IN | 使用者登入(含確認信完成、頁面重新載入時還原 session) |
SIGNED_OUT | 使用者登出 |
TOKEN_REFRESHED | access token 自動續期 |
USER_UPDATED | 呼叫 updateUser 更新使用者 |
PASSWORD_RECOVERY | 使用者點了密碼重設信、進入恢復狀態 |
它讓你的 UI 能即時反應——登入後自動切到 dashboard、登出後自動清畫面——而不用到處手動輪詢。
實作範例
以下範例用 supabase-js(TypeScript),可在前端專案直接套用。我們從建立 client 開始,依「註冊 → 監聽 → 登入 → 登出 → 密碼重設」的順序逐段實作。
一、建立 Supabase Client
import { createClient } from '@supabase/supabase-js'
export const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)
二、註冊:signUp 與 Email 確認的判斷
註冊時,重點是正確判讀 signUp() 的回傳——尤其在開啟 confirm email 時,如何分辨「要去收信」「email 已被使用」這兩種情況:
async function signUp(email: string, password: string) {
const { data, error } = await supabase.auth.signUp({
email,
password,
options: {
// 使用者點確認信後要導回的網址(必須加進 Redirect URL Allowlist!)
emailRedirectTo: 'https://example.com/welcome',
// 附加 user metadata(會進 raw_user_meta_data,可被 trigger 取用)
data: { full_name: 'Ben Chang' },
},
})
if (error) {
console.error('註冊失敗:', error.message)
return
}
// 防帳號列舉:email 已存在時,Supabase 不報錯,而是回傳 identities 為空的 user
if (data.user && data.user.identities?.length === 0) {
console.warn('此 email 已被註冊')
return
}
// confirm email 開啟時 data.session 為 null —— 這不是錯誤,是要去收信
if (!data.session) {
console.log('註冊成功,請至信箱點擊確認連結以啟用帳號')
} else {
console.log('註冊並自動登入成功(confirm email 未開啟)')
}
}
三種結果都要處理:真的錯誤(error)、email 已存在(identities 為空)、以及成功但需確認(session 為 null)。少判斷任何一種,使用者體驗就會出現破洞。
三、監聽狀態:onAuthStateChange
在應用啟動時就掛上監聽器,讓 UI 隨登入狀態自動更新。注意最後回傳的 unsubscribe:
const { data: { subscription } } = supabase.auth.onAuthStateChange(
(event, session) => {
console.log('驗證事件:', event)
if (event === 'SIGNED_IN') {
// 登入完成(含確認信完成、重新整理還原 session)→ 更新 UI
console.log('已登入:', session?.user.email)
}
if (event === 'SIGNED_OUT') {
// 登出 → 清掉使用者資訊、導回登入頁
console.log('已登出')
}
if (event === 'PASSWORD_RECOVERY') {
// 使用者點了密碼重設信 → 顯示「設定新密碼」表單
console.log('進入密碼恢復狀態')
}
}
)
// 元件卸載時務必取消訂閱,避免記憶體洩漏與重複監聽
// subscription.unsubscribe()
小提醒:不要在這個 callback 裡直接
await其他 Supabase 呼叫,容易造成死結(deadlock)。若必須在事件裡打 API,用setTimeout(() => { ... }, 0)包起來延後執行。
四、登入與登出
// 登入
async function signIn(email: string, password: string) {
const { data, error } = await supabase.auth.signInWithPassword({
email,
password,
})
if (error) {
// 常見錯誤:'Invalid login credentials'(帳號或密碼錯誤,兩者不區分以防列舉)
// 若尚未確認 email,會是 'Email not confirmed'
console.error(error.message)
return
}
// data.session 就是那組 { access_token, refresh_token }
console.log('登入成功:', data.user.email)
}
// 登出(scope 預設 'local',只登出當前裝置)
async function signOut() {
const { error } = await supabase.auth.signOut({ scope: 'local' })
if (error) console.error('登出失敗:', error.message)
else console.log('已登出')
}
登入的錯誤處理有個實務重點:'Invalid login credentials' 同時代表「帳號不存在」與「密碼錯誤」——Supabase 刻意不區分以防止帳號列舉。前端顯示時請給通用訊息「Email 或密碼錯誤」,別自作聰明地猜是哪一種。
五、密碼重設:完整兩步
第一步(忘記密碼頁):發送重設信。
async function requestPasswordReset(email: string) {
const { error } = await supabase.auth.resetPasswordForEmail(email, {
// 使用者點信中連結後導回的「重設密碼頁」(同樣必須進 Allowlist)
redirectTo: 'https://example.com/auth/reset-password',
})
if (error) console.error('發送失敗:', error.message)
else console.log('重設信已寄出,請至信箱查收')
}
第二步(重設密碼頁):使用者點信回到 /auth/reset-password 後,此時已處於恢復 session(onAuthStateChange 會收到 PASSWORD_RECOVERY),在此頁呼叫 updateUser 寫入新密碼:
async function updatePassword(newPassword: string) {
// 使用者點了重設信、已處於「已驗證的恢復狀態」,才有權改密碼
const { data, error } = await supabase.auth.updateUser({
password: newPassword,
})
if (error) {
console.error('更新密碼失敗:', error.message)
return
}
console.log('密碼已更新,請用新密碼登入:', data.user.email)
}
把兩步接起來:使用者在「忘記密碼頁」輸入 email → 收信 → 點連結回到「重設密碼頁」→ 輸入新密碼 → updateUser 完成。中間那條連結能不能成功回到你的網站,取決於 redirectTo 有沒有在 Allowlist 裡。
六、Redirect URL Allowlist 設定
上面 emailRedirectTo 與 redirectTo 用到的每一個網址,都必須在 Dashboard → Authentication → URL Configuration 加入白名單。典型設定:
# 生產環境
https://example.com/**
# 本地開發(** 匹配含斜線的任意路徑,開發推薦)
http://localhost:3000/**
沒設這一步,是本篇所有「點了信卻回不來」問題的頭號元兇。
常見錯誤與最佳實踐
坑一:以為 signUp() 沒回 session 就是失敗。
開啟 confirm email 時,signUp() 回傳的 data.session 本來就是 null——它只是建了待確認帳號並寄信,不是錯誤。有人因此加了一堆「重試」或誤導使用者「註冊失敗」。正確做法:判斷 data.session 是否存在,若為 null 就顯示「請至信箱點擊確認連結」,把使用者導向收信,而不是報錯。
坑二:redirectTo / emailRedirectTo 沒加進 Allowlist。
這是最高頻的斷點。信寄出去了、使用者也點了,卻回不到你的網站——因為 Supabase 基於安全,只允許導向白名單內的網址。正確做法:把每一個會用到的 redirect 網址(生產、localhost、Vercel preview…)都加進 Dashboard → Authentication → URL Configuration,開發用 /** 萬用字元涵蓋子路徑。
坑三:密碼重設流程只做了第一步。
只呼叫了 resetPasswordForEmail 就以為結束,忘了它「只發信、不改密碼」。使用者點連結回來後,沒有一個頁面呼叫 updateUser,於是密碼永遠沒被改。正確做法:務必建「重設密碼頁」,在該頁(或 PASSWORD_RECOVERY 事件中)呼叫 updateUser({ password }) 才算真正完成。
坑四:登入錯誤訊息洩漏帳號是否存在。
有人想「貼心」地區分「這個 email 沒註冊」和「密碼錯了」,反而製造了帳號列舉漏洞——攻擊者可藉此得知哪些 email 是註冊過的。Supabase 本身就統一回 'Invalid login credentials'。正確做法:前端也統一顯示通用訊息「Email 或密碼錯誤」,不要透露是哪一項出錯。
坑五:onAuthStateChange 沒取消訂閱、或在 callback 裡 await。
監聽器掛了卻沒在元件卸載時 unsubscribe,會造成記憶體洩漏與重複觸發;在 callback 裡直接 await Supabase 呼叫則可能死結。正確做法:保存回傳的 subscription,卸載時呼叫 subscription.unsubscribe();若非在事件中打 API 不可,用 setTimeout(..., 0) 延後執行。
最佳實踐總結:
- 正式環境開 confirm email:確保 email 屬於本人,並正確處理
session為null的情況 - 所有 redirect 網址進 Allowlist:
emailRedirectTo、redirectTo一個都不能漏 - 密碼重設做滿兩步:
resetPasswordForEmail發信 +updateUser寫密碼,缺一不可 - 錯誤訊息保持通用:登入失敗一律「Email 或密碼錯誤」,不洩漏帳號存在與否
- 監聽器管好生命週期:
onAuthStateChange記得unsubscribe,callback 內避免直接await
小結
這是 Supabase 系列教學的第 020 篇。上一篇《Auth 入門》我們建立了 session、JWT 與 RLS 整合的地基,並在文末預告要把它落地——這一篇就是兌現:用最經典的 Email/Password,走完註冊、Email 確認、登入、登出到密碼重設的完整循環。
核心觀念濃縮成一句話:Email/Password 驗證的主線是「signUp 註冊 →(若開 confirm email)點確認信 → signInWithPassword 登入 → session → signOut 登出」;忘記密碼則走「resetPasswordForEmail 發信 → 點連結進恢復狀態 → updateUser 寫新密碼」的兩步分支;全程用 onAuthStateChange 被動監聽狀態變化;而 confirm email 開關與 Redirect URL Allowlist,是決定流程順不順的兩顆關鍵設定鈕。
signUp+ confirm email:開啟時session為null是正常的,要引導收信而非報錯- 登入登出:
signInWithPassword拿 session,signOut用scope控制登出範圍 - 密碼重設兩步:
resetPasswordForEmail(發信)+updateUser(寫密碼),缺一不可 onAuthStateChange:被動監聽SIGNED_IN/SIGNED_OUT/PASSWORD_RECOVERY,記得unsubscribe- 設定面:確認信樣板的
{{ .ConfirmationURL }}與 Redirect URL Allowlist 缺一,流程就斷
把 Email/Password 這條路走通後,你會發現其他登入方式只是換了入口、出口一致。下一篇《OAuth 社群登入》,我們讓使用者用 Google、GitHub 一鍵登入,實作 signInWithOAuth、callback 交換與 PKCE 流程——同樣一組 session,換一種更省事的進門方式。下一篇見。