Supabase Magic Link 與 OTP:無密碼登入實作 | Supabase 完整教學
前兩篇我們讓使用者用 email 加密碼、或用 Google/GitHub 社群帳號登入。這一篇走向另一個極端——完全不要密碼:使用者只留一個 email,我們寄一條神奇連結(Magic Link)或一組 6 碼 OTP,點一下或輸入六位數就登入。核心是一支
signInWithOtp,寄連結還是寄驗證碼只差在 email 樣板用{{ .ConfirmationURL }}還是{{ .Token }};驗證碼那條再用verifyOtp收尾。我們也會講shouldCreateUser、簡訊 OTP、以及限流與過期這些實務細節。
前言
一句話定義本篇主題:無密碼登入(passwordless)是讓使用者「不設定、也不輸入密碼」就完成登入——他只提供一個 email(或手機號),Supabase 寄出一個一次性憑證,使用者點擊連結(Magic Link)或輸入 6 碼驗證碼(OTP)即可換到 session。前端只需一支 signInWithOtp 觸發寄送;走 OTP code 時再用 verifyOtp 驗證那組碼。而「寄的是連結還是驗證碼」,取決於你的 email 樣板放的是 {{ .ConfirmationURL }} 還是 {{ .Token }}。
延續前兩篇反覆強調的那句話:無論用哪種登入方式,最終產物都是同一組 session(access token + refresh token)。無密碼登入換掉的只是「證明你是這個 email 的主人」這一段——不再靠密碼,而是靠「你能收到寄到這個信箱的一次性憑證」。一旦拿到 session,後面的 getUser()、RLS 整合、onAuthStateChange 全都和前兩篇一模一樣。所以你只需要把注意力放在「無密碼特有的那段寄送與驗證」。
用一個類比幫你抓住全貌:無密碼登入就像進飯店房間的兩種方式。 Magic Link 像是櫃檯直接遞給你一張「感應房卡」——你走到門口輕輕一刷(點一下連結)門就開了,最省事,但這張卡只能在「拿到它的那台裝置」上刷。OTP code 則像是櫃檯給你一組「門鎖密碼」——你走到任何一扇你有權進入的門前,手動輸入這組 6 位數(verifyOtp)就能開,不受「卡在哪台裝置」限制,代價是多打幾個數字。兩種方式,開的都是同一間房(同一組 session),只是遞鑰匙的形式不同。
本篇你會學到:
- 一支 API 兩種形態:
signInWithOtp如何同時支撐 Magic Link 與 OTP code,差別只在 email 樣板 - 樣板切換:
{{ .ConfirmationURL }}(連結)vs{{ .Token }}(6 碼)的意義與客製 - OTP 驗證:
verifyOtp({ email, token, type: 'email' })如何把使用者輸入的 6 碼換成 session - 註冊控制:
shouldCreateUser如何決定「查無此 email 時要不要自動建帳號」 - 簡訊 OTP:
signInWithOtp({ phone })+verifyOtp({ type: 'sms' })的差異 - 實務底線:Magic Link vs OTP 的 UX 取捨、連結過期、限流、以及開放註冊的風險
核心概念
無密碼登入的整條路徑,可以濃縮成一句話:前端 signInWithOtp 寄出一次性憑證 → 使用者收信 → 點連結(自動登入)或輸入 6 碼(verifyOtp 驗證)→ 拿到 session。先把這條線在腦中畫清楚,實作就只是把每一段接上對應的 API 與樣板設定。
一、一支 signInWithOtp,兩種形態
最容易讓人困惑的一點:Magic Link 和 Email OTP 不是兩支不同的 API,而是同一支 signInWithOtp({ email }) 的兩種呈現。它們共用同一套底層——都是產生一個「一次性 token」寄到使用者信箱。差別只在那封信「怎麼呈現這個 token」:
- 把 token 包進一個可點擊的網址 → 使用者點下去自動登入 → 這就是 Magic Link。
- 把 token(的短碼版本,6 位數)直接印出來 → 使用者手動輸入 → 這就是 Email OTP。
決定要哪一種的,不是程式碼,而是 Dashboard → Authentication → Email Templates 裡「Magic Link」那封信的樣板變數:
| 你在樣板放什麼 | 使用者收到 | 收尾方式 |
|---|---|---|
{{ .ConfirmationURL }} | 一條可點擊連結(Magic Link) | 點連結 → 自動走 callback,無需 verifyOtp |
{{ .Token }} | 一組 6 位數驗證碼(OTP) | 使用者輸入 → 前端呼叫 verifyOtp 換 session |
換句話說,前端那行 signInWithOtp({ email: '...' }) 兩種情況寫得一模一樣;你要改的是後台那封信的 HTML。這也是本篇最重要的心智模型:API 統一、樣板分流。
二、Magic Link 流程
Magic Link 走的是「連結內含 token」的自動流程,使用者全程不用手動輸入任何驗證碼:
【你的 App】使用者輸入 email,按「寄送登入連結」
│
└─ 前端呼叫 signInWithOtp({ email, options: { emailRedirectTo } })
│
└─ Supabase 產生一次性 token,包進 {{ .ConfirmationURL }} 寄出
│
└─【使用者信箱】收到一封含「登入連結」的信
│
└─ 使用者點連結 → 導向 Supabase 驗證
│
└─ 驗證成功 → 導回你的 emailRedirectTo
(SSR 情境於 callback 建立 session)
│
└─ 使用者正式登入
重點:Magic Link 的「收尾」是點連結,這一步之後的處理(SSR 情境下的 callback)和上一篇《OAuth 社群登入》幾乎一致——都是瀏覽器被導回你的網址、由你的 callback 把 session 寫進 cookie。你不需要自己呼叫 verifyOtp。
三、OTP code 流程
OTP code 走的是「使用者手動輸入 6 碼」的流程,多了一個 verifyOtp 的收尾步驟:
【你的 App】使用者輸入 email,按「寄送驗證碼」
│
└─ 前端呼叫 signInWithOtp({ email })(樣板已改為顯示 {{ .Token }})
│
└─ Supabase 產生一次性 token,以 6 碼形式寄出
│
└─【使用者信箱】收到「你的驗證碼是 123456」
│
└─ 使用者回到你的 App 輸入 123456
│
└─ 前端呼叫 verifyOtp({ email, token, type: 'email' })
│
└─ 驗證成功 → 直接拿到 session(使用者留在原頁)
OTP code 的最大優勢:session 建在「使用者輸入碼的那台裝置」,天然支援跨裝置(手機收信、電腦登入),也不怕企業郵件的連結預抓機制。代價是多了「切視窗、複製貼上」這一步。
四、Magic Link vs OTP code:UX 取捨
兩者背後是同一組 session,選哪個純粹是體驗與可靠度的取捨:
| 面向 | Magic Link | OTP code |
|---|---|---|
| 使用者動作 | 點一下連結 | 手動輸入 6 碼 |
| 樣板變數 | {{ .ConfirmationURL }} | {{ .Token }} |
| 前端收尾 | 走 callback(免 verifyOtp) | 呼叫 verifyOtp |
| session 建在哪 | 開信的那台裝置 | 輸入碼的那台裝置 |
| 跨裝置(手機收信、電腦登入) | 差(裝置錯位) | 佳(天然支援) |
| 原生 App | 差(deep link 麻煩) | 佳(原地輸入) |
| 連結被郵件安全閘道預抓 | 可能誤觸失效 | 不受影響 |
| 摩擦 | 最低(一鍵) | 略高(切窗輸入) |
| 最佳場景 | 純桌面 Web | 行動 App、跨裝置、企業郵件環境 |
一句話記法:純桌面 Web 用 Magic Link 換一鍵順暢;只要牽涉跨裝置或原生 App,用 OTP code 更穩。
五、關鍵術語小抄
| 術語 | 意義 |
|---|---|
signInWithOtp | 觸發寄送無密碼憑證的 API(email 或 phone) |
verifyOtp | 驗證使用者輸入的 6 碼、換回 session |
| Magic Link | 內含一次性 token 的可點擊登入連結 |
| OTP | One-Time Password,一次性 6 碼驗證碼 |
{{ .ConfirmationURL }} | email 樣板變數:渲染成 Magic Link 網址 |
{{ .Token }} | email 樣板變數:渲染成 6 位數驗證碼 |
shouldCreateUser | 查無此 email 時是否自動建立帳號(預設 true) |
type(在 verifyOtp) | 驗證類型:'email'/'sms'/'phone_change' 等 |
emailRedirectTo | Magic Link 點擊後導回的你方網址 |
實作範例
以下用 supabase-js(TypeScript)示範,涵蓋 Magic Link、Email OTP、簡訊 OTP、shouldCreateUser,以及 email 樣板的切換。我們依「寄送 → 驗證 → 樣板 → 註冊控制」的順序逐段實作。
一、建立 Supabase Client
無密碼登入在純前端與 SSR 都能用。這裡先給最通用的瀏覽器端 client(SSR 的 @supabase/ssr 版本與上一篇相同,Magic Link 的 callback 也沿用上一篇的 /auth/callback):
// utils/supabase/client.ts
import { createBrowserClient } from '@supabase/ssr'
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)
}
二、寄送 Magic Link
Magic Link 只需一支 signInWithOtp,重點是 emailRedirectTo——使用者點連結、Supabase 驗證後,會把他導回這個網址(必須在 Dashboard 的 Redirect URL Allowlist):
'use client'
import { createClient } from '@/utils/supabase/client'
async function sendMagicLink(email: string) {
const supabase = createClient()
const { error } = await supabase.auth.signInWithOtp({
email,
options: {
// 使用者點連結、驗證通過後導回這裡(要在 Redirect URL Allowlist)
emailRedirectTo: `${window.location.origin}/auth/callback`,
// 查無此 email 時是否自動建帳號,預設 true(見後面章節)
shouldCreateUser: true,
},
})
if (error) {
// 常見:限流(60 秒內重複請求)→ error.message 會提示 rate limit
console.error('寄送失敗:', error.message)
return
}
// 注意:此時「還沒登入」,只是信寄出去了
console.log('登入連結已寄出,請到信箱點擊')
}
一定要理解:這裡沒有 session。signInWithOtp 的產物是「一封已寄出的信」,session 要等使用者點了連結、繞回你的 callback 才拿得到。前提是這封「Magic Link」樣板放的是 {{ .ConfirmationURL }}(Supabase 預設就是)。
點連結後的 callback 處理,和上一篇《OAuth 社群登入》完全共用——SSR 情境下同樣在 /auth/callback route 收尾。這正是那句老話的體現:出口一致,換的只是入口。
三、寄送 Email OTP(6 碼)並驗證
若要走「輸入 6 碼」的體驗,前端寄送這支呼叫跟 Magic Link 幾乎一樣(甚至可以不帶 emailRedirectTo),差別在於:你要先把 email 樣板改成顯示 {{ .Token }}(下一節說明),然後多一步 verifyOtp:
'use client'
import { createClient } from '@/utils/supabase/client'
// 第一步:寄送 6 碼驗證碼(樣板需顯示 {{ .Token }})
async function sendEmailOtp(email: string) {
const supabase = createClient()
const { error } = await supabase.auth.signInWithOtp({
email,
options: { shouldCreateUser: true },
})
if (error) {
console.error('寄送驗證碼失敗:', error.message)
return
}
console.log('驗證碼已寄出,請查看信箱')
}
// 第二步:使用者輸入收到的 6 碼後,驗證換 session
async function verifyEmailOtp(email: string, token: string) {
const supabase = createClient()
const { data, error } = await supabase.auth.verifyOtp({
email, // 必須是「當初請求 OTP 的同一個 email」
token, // 使用者輸入的 6 位數,例如 '123456'
type: 'email', // Email OTP 固定用 'email'
})
if (error) {
// 常見:Token has expired or is invalid(碼錯、過期、或 email 對不上)
console.error('驗證失敗:', error.message)
return
}
// 成功!data.session 就是登入後的 session(access + refresh token)
console.log('登入成功:', data.user?.email)
}
兩個要點:一、verifyOtp 的 email 必須和寄送時的 email 一致(大小寫、空白都要對得上);二、type 對 Email OTP 固定是 'email'——這是最容易傳錯的參數之一。驗證成功後 data.session 直接就緒,使用者不需離開當前頁面。
一個常見的搭配是把兩步組進一個簡單的 React 元件:
'use client'
import { useState } from 'react'
import { createClient } from '@/utils/supabase/client'
export function OtpLogin() {
const supabase = createClient()
const [email, setEmail] = useState('')
const [token, setToken] = useState('')
const [sent, setSent] = useState(false)
async function handleSend() {
const { error } = await supabase.auth.signInWithOtp({ email })
if (!error) setSent(true) // 進入「輸入驗證碼」階段
}
async function handleVerify() {
const { error } = await supabase.auth.verifyOtp({
email,
token,
type: 'email',
})
if (!error) alert('登入成功!')
}
return (
<div>
{!sent ? (
<>
<input
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="輸入 email"
/>
<button onClick={handleSend}>寄送驗證碼</button>
</>
) : (
<>
<input
value={token}
onChange={(e) => setToken(e.target.value)}
placeholder="輸入 6 位數驗證碼"
/>
<button onClick={handleVerify}>驗證並登入</button>
</>
)}
</div>
)
}
四、切換 email 樣板:{{ .ConfirmationURL }} vs {{ .Token }}
這是決定「使用者收到連結還是驗證碼」的地方,位於 Dashboard → Authentication → Email Templates → Magic Link。預設樣板長這樣(Magic Link):
<!-- 預設:ConfirmationURL → 使用者收到「可點擊的登入連結」 -->
<h2>Magic Link</h2>
<p>Follow this link to login:</p>
<p><a href="{{ .ConfirmationURL }}">Log In</a></p>
若要改成寄「6 碼驗證碼」,把樣板改成顯示 {{ .Token }}:
<!-- 改為 Token → 使用者收到「6 位數驗證碼」 -->
<h2>您的登入驗證碼</h2>
<p>請在 App 中輸入以下驗證碼完成登入:</p>
<p style="font-size: 28px; font-weight: bold; letter-spacing: 4px;">
{{ .Token }}
</p>
<p>此驗證碼將於數分鐘後失效,請儘速使用。</p>
也可以兩者都放,讓使用者自己選(桌面點連結、跨裝置輸入碼):
<!-- 同一封信同時提供連結與驗證碼 -->
<h2>登入 BenzHub</h2>
<p>點擊連結,或輸入下方驗證碼皆可登入:</p>
<p><a href="{{ .ConfirmationURL }}">點此登入</a></p>
<p>驗證碼:<strong>{{ .Token }}</strong></p>
其他常用樣板變數還有 {{ .SiteURL }}(你的站點網址)、{{ .Email }}(收件者 email)、{{ .TokenHash }}(token 的雜湊,SSR PKCE 的 /auth/confirm route 會用到)。記住核心對照:ConfirmationURL = 連結,Token = 6 碼。
五、簡訊(SMS)OTP
無密碼登入也能走手機號。差別是:需要先在 Dashboard → Authentication → Providers → Phone 設定 SMS 供應商(如 Twilio),前端則把 email 換成 phone、type 換成 'sms':
// 第一步:寄送簡訊驗證碼(phone 用 E.164 格式,含國碼)
async function sendSmsOtp(phone: string) {
const supabase = createClient()
const { error } = await supabase.auth.signInWithOtp({
phone, // 例如 '+886912345678'
})
if (error) console.error('簡訊寄送失敗:', error.message)
}
// 第二步:驗證簡訊 6 碼
async function verifySmsOtp(phone: string, token: string) {
const supabase = createClient()
const { data, error } = await supabase.auth.verifyOtp({
phone,
token,
type: 'sms', // 簡訊固定用 'sms'
})
if (!error) console.log('手機登入成功:', data.session?.user.id)
}
簡訊 OTP 天生就是「輸入碼」模式(簡訊裡不會有網址讓你點著登入),所以概念上等同 Email OTP,只是渠道換成 SMS。注意 SMS 有實際費用,務必配合限流與 CAPTCHA 防止被濫用刷簡訊(見下一節)。
六、shouldCreateUser:控制要不要自動註冊
signInWithOtp 有一個關鍵的行為預設值:shouldCreateUser 預設為 true——也就是說,只要有人輸入一個「系統裡還不存在」的 email 並請求 OTP,Supabase 就會自動幫他建立一個新帳號。這對「註冊與登入合一」的產品很方便,但如果你的產品只想讓既有使用者用無密碼方式登入(例如受邀制、或註冊走另一條流程),就要把它設為 false:
// 只允許「已存在的使用者」用 OTP 登入;查無此 email 直接回錯,不建帳號
const { error } = await supabase.auth.signInWithOtp({
email,
options: {
shouldCreateUser: false,
},
})
if (error) {
// 查無此使用者時會回錯(訊息刻意模糊,避免洩漏「此 email 是否已註冊」)
console.error(error.message)
}
一句話:shouldCreateUser: true(預設)=「登入即註冊」;false =「只讓老使用者進門」。 根據你的產品是否想開放任何人用 email 自助註冊來決定——這也直接關係到下一節要談的「開放註冊」風險。
常見錯誤與最佳實踐
坑一:email 樣板用錯 token 變數,行為和預期相反。
最常見的困惑:你想寄 6 碼 OTP,程式碼也呼叫了 signInWithOtp + 準備了 verifyOtp,但使用者收到的卻是一條連結(因為樣板還是預設的 {{ .ConfirmationURL }});或反過來,你想要 Magic Link,卻把樣板改成了 {{ .Token }},使用者收到一串數字卻無處可點。正確做法:先想清楚要哪種體驗,再去 Dashboard → Email Templates 對齊——OTP 用 {{ .Token }}、Magic Link 用 {{ .ConfirmationURL }},或兩者都放。記住:API 統一、樣板分流,行為由樣板決定。
坑二:Magic Link 連結過期或被「預抓」失效。 Magic Link/Email OTP 預設有效期約 1 小時,超時點擊就會失效;更隱蔽的是,部分企業郵件的安全閘道會自動預抓(prefetch)信裡的連結做安全掃描,等於幫使用者「點」掉了那條一次性連結,使用者本人再點時就顯示已失效。正確做法:把 OTP/連結有效期設得合理(OTP 建議 5-10 分鐘,過短使用者來不及、過長不安全);若你的使用者多在企業郵件環境、常遇到連結被預抓,優先改用 OTP code——一串數字不會被閘道「點掉」。跨裝置需求(手機收信、電腦登入)也一律建議 OTP。
坑三:忽略限流,被使用者(或攻擊者)狂寄信/簡訊。
Supabase 對 OTP 寄送有內建限流:同一 email/phone 大約每 60 秒只能請求一次,且每 IP 每小時有次數上限(預設約 30 次)。若你的前端沒處理限流錯誤,使用者猛按「重寄」就會收到一堆失敗;更嚴重的是簡訊 OTP——每封 SMS 都是真金白銀,被惡意腳本刷爆會產生大量費用。正確做法:一、前端加「倒數 60 秒才能重寄」的節流 UI;二、對寄送 OTP 的操作啟用 CAPTCHA(hCaptcha/Turnstile),把 captchaToken 帶進 options;三、生產環境務必到 Dashboard → Authentication → Rate Limits 檢視並收緊限制,尤其是 Phone OTP。此外 Email OTP 若用 Supabase 內建 SMTP 有 2 封/小時的硬限制,正式上線務必設定自訂 SMTP。
坑四:shouldCreateUser 預設開放註冊,被灌爆幽靈帳號。
shouldCreateUser 預設 true,代表任何人打任何 email 進去請求 OTP,都會被建成一個新使用者(即使他從未驗證)。若你的產品不打算開放任意 email 自助註冊,這會讓 auth.users 塞滿一堆沒完成登入的幽靈帳號,甚至被拿來做 email 枚舉或濫發信。正確做法:受邀制/封閉產品把 shouldCreateUser 設為 false,只允許既有使用者登入;開放註冊的產品則務必搭配 CAPTCHA 與限流,避免自動化腳本大量建帳號。
最佳實踐總結:
- 樣板對齊體驗:OTP 用
{{ .Token }}、Magic Link 用{{ .ConfirmationURL }},別搞反 - 跨裝置/企業郵件優先用 OTP code:避開裝置錯位與連結預抓
- 設好有效期:OTP 建議 5-10 分鐘,兼顧安全與可用性
- 限流+CAPTCHA:尤其 SMS OTP,防刷信與費用濫用;正式站設自訂 SMTP
- 想清楚
shouldCreateUser:受邀制設false,開放註冊要有防濫用機制 - 出口一致:無論 Magic Link 或 OTP,拿到的都是同一組 session,登入後邏輯完全共用
小結
這是 Supabase 系列教學的第 022 篇。上一篇《OAuth 社群登入》我們讓使用者「借用 Google/GitHub 的身份」進門,繞完一圈拿到同一組 session。這一篇更進一步——連密碼、連第三方帳號都不要:使用者只留一個 email(或手機號),我們寄一條 Magic Link 或一組 OTP,點一下或輸入六位數就登入。
核心觀念濃縮成一句話:無密碼登入的主線是「前端 signInWithOtp 寄出一次性憑證 → 使用者點 Magic Link 自動登入、或輸入 6 碼由 verifyOtp 驗證 → 拿到同一組 session」;而『寄的是連結還是驗證碼』完全由 email 樣板決定——{{ .ConfirmationURL }} 給連結、{{ .Token }} 給 6 碼,API 統一、樣板分流;再用 shouldCreateUser 控制要不要自動註冊,並在限流、過期、CAPTCHA 上守好防線。
- 一支 API 兩形態:
signInWithOtp({ email })寄連結或驗證碼,差別在樣板 - 樣板切換:
{{ .ConfirmationURL }}(Magic Link)vs{{ .Token }}(OTP code) - OTP 驗證:
verifyOtp({ email, token, type: 'email' }),簡訊則type: 'sms' - UX 取捨:桌面 Web 用 Magic Link,跨裝置/原生 App/企業郵件用 OTP code
- 註冊與安全:
shouldCreateUser控制自動建帳號,限流+CAPTCHA 防濫用
到這裡,你已經集滿了「有密碼」「借用第三方」「完全無密碼」三種入口,全都通往同一組 session。下一篇《MFA 與 SSR》,我們要往安全與架構更深處走——先幫登入加上第二道防線(多因子驗證,MFA/TOTP),再處理**伺服器端渲染(SSR)**環境下該怎麼用 cookie 安全地保管 session、以及 middleware 如何在每個請求刷新 token。登入的「進門」講完了,接下來談「進門之後怎麼守得更穩」。下一篇見。