Supabase魔法链接 #

一、魔法链接概述 #

1.1 什么是魔法链接 #

text
魔法链接登录流程
├── 1. 用户输入邮箱地址
├── 2. 系统发送包含链接的邮件
├── 3. 用户点击邮件中的链接
├── 4. 自动完成登录
└── 5. 无需记忆密码

1.2 魔法链接优势 #

优势 说明
无密码 用户无需记忆密码
安全 链接一次性使用,有效期短
便捷 一键登录
减少支持 无密码重置问题

二、发送魔法链接 #

2.1 基础用法 #

typescript
// 发送魔法链接
const { data, error } = await supabase.auth.signInWithOtp({
  email: 'user@example.com',
})

2.2 带配置发送 #

typescript
// 带自定义配置
const { data, error } = await supabase.auth.signInWithOtp({
  email: 'user@example.com',
  options: {
    emailRedirectTo: 'https://example.com/auth/callback',
    data: {
      full_name: 'John Doe',
    },
  },
})

2.3 魔法链接表单 #

tsx
import { useState } from 'react'
import { supabase } from '../lib/supabase'

export function MagicLinkForm() {
  const [email, setEmail] = useState('')
  const [loading, setLoading] = useState(false)
  const [sent, setSent] = useState(false)
  const [error, setError] = useState('')

  const handleSendMagicLink = async (e: React.FormEvent) => {
    e.preventDefault()
    setLoading(true)
    setError('')

    const { error } = await supabase.auth.signInWithOtp({
      email,
      options: {
        emailRedirectTo: window.location.origin + '/auth/callback',
      },
    })

    setLoading(false)

    if (error) {
      setError(error.message)
    } else {
      setSent(true)
    }
  }

  if (sent) {
    return (
      <div className="success">
        <h2>Check your email</h2>
        <p>We sent a magic link to {email}</p>
        <button onClick={() => setSent(false)}>
          Resend
        </button>
      </div>
    )
  }

  return (
    <form onSubmit={handleSendMagicLink}>
      <h2>Sign in with magic link</h2>
      <input
        type="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        placeholder="Enter your email"
        required
      />
      <button type="submit" disabled={loading}>
        {loading ? 'Sending...' : 'Send Magic Link'}
      </button>
      {error && <p className="error">{error}</p>}
    </form>
  )
}

三、处理魔法链接回调 #

3.1 自动处理 #

typescript
// Supabase客户端自动处理魔法链接
// 只需确保redirectTo页面正确配置

const supabase = createClient(url, key, {
  auth: {
    autoRefreshToken: true,
    persistSession: true,
    detectSessionInUrl: true,  // 自动检测URL中的token
  },
})

3.2 手动处理回调 #

typescript
// auth/callback.tsx
import { useEffect } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { supabase } from '../lib/supabase'

export function MagicLinkCallback() {
  const [searchParams] = useSearchParams()
  const navigate = useNavigate()

  useEffect(() => {
    const handleCallback = async () => {
      // 获取URL中的token
      const token = searchParams.get('token')
      const type = searchParams.get('type')

      if (token && type === 'magiclink') {
        const { error } = await supabase.auth.verifyOtp({
          token_hash: token,
          type: 'magiclink',
        })

        if (error) {
          console.error('Magic link error:', error)
          navigate('/login?error=invalid_link')
        } else {
          navigate('/dashboard')
        }
      }
    }

    handleCallback()
  }, [searchParams, navigate])

  return <div>Logging in...</div>
}

四、配置魔法链接 #

4.1 Dashboard配置 #

text
Dashboard > Authentication > Providers > Email

魔法链接配置
├── Enable Magic Link
│   └── 启用魔法链接功能
│
├── Secure email change
│   └── 更改邮箱时发送确认邮件
│
└── Token expiry
    └── 链接有效期(默认24小时)

4.2 邮件模板配置 #

text
Dashboard > Authentication > Email Templates

Magic Link模板
├── Subject: Your Magic Link
├── Content:
│   <h2>Magic Link</h2>
│   <p>Click the link below to sign in:</p>
│   <a href="{{ .ConfirmationURL }}">
│     Sign in to {{ .ProjectName }}
│   </a>
│   <p>Link expires in 24 hours.</p>
└── 可用变量:
    ├── {{ .ConfirmationURL }} - 魔法链接URL
    ├── {{ .Token }} - Token值
    ├── {{ .TokenHash }} - Token哈希
    ├── {{ .Email }} - 用户邮箱
    └── {{ .ProjectName }} - 项目名称

4.3 自定义邮件模板 #

html
<!-- 自定义魔法链接邮件模板 -->
<!DOCTYPE html>
<html>
<head>
  <style>
    body { font-family: Arial, sans-serif; }
    .container { max-width: 600px; margin: 0 auto; }
    .button { 
      background: #3ECF8E; 
      color: white; 
      padding: 12px 24px;
      text-decoration: none;
      border-radius: 8px;
    }
  </style>
</head>
<body>
  <div class="container">
    <h1>Welcome to {{ .ProjectName }}</h1>
    <p>Click the button below to sign in:</p>
    <a href="{{ .ConfirmationURL }}" class="button">
      Sign In
    </a>
    <p>Or copy this link:</p>
    <code>{{ .ConfirmationURL }}</code>
    <p><small>This link expires in 24 hours.</small></p>
  </div>
</body>
</html>

五、PKCE流程 #

5.1 什么是PKCE #

text
PKCE (Proof Key for Code Exchange)
├── 增强OAuth安全性
├── 防止授权码拦截攻击
├── 适用于移动端和SPA
└── 推荐用于魔法链接

5.2 使用PKCE #

typescript
// 使用PKCE流程
const { data, error } = await supabase.auth.signInWithOtp({
  email: 'user@example.com',
  options: {
    shouldCreateUser: true,
    emailRedirectTo: 'https://example.com/auth/callback',
  },
})

六、魔法链接vs密码登录 #

6.1 对比 #

特性 魔法链接 密码登录
用户体验 一键登录 需输入密码
安全性 无密码泄露风险 密码可能泄露
依赖 需要邮箱访问 仅需邮箱
登录速度 需等待邮件 即时登录
离线使用 不支持 支持

6.2 选择建议 #

text
推荐使用魔法链接的场景
├── 安全性要求高
├── 用户经常忘记密码
├── 移动端应用
└── 希望简化登录流程

推荐使用密码登录的场景
├── 需要离线登录
├── 企业内网应用
├── 邮件系统不可靠
└── 用户习惯密码登录

七、结合使用 #

7.1 提供多种登录方式 #

tsx
export function LoginPage() {
  const [method, setMethod] = useState<'password' | 'magic'>('magic')

  return (
    <div>
      <div className="tabs">
        <button 
          onClick={() => setMethod('magic')}
          className={method === 'magic' ? 'active' : ''}
        >
          Magic Link
        </button>
        <button 
          onClick={() => setMethod('password')}
          className={method === 'password' ? 'active' : ''}
        >
          Password
        </button>
      </div>

      {method === 'magic' ? (
        <MagicLinkForm />
      ) : (
        <PasswordForm />
      )}
    </div>
  )
}

八、重新发送魔法链接 #

8.1 限制频率 #

typescript
// 防止滥用
let lastSentTime = 0
const COOLDOWN = 60000 // 60秒冷却

async function sendMagicLink(email: string) {
  const now = Date.now()
  if (now - lastSentTime < COOLDOWN) {
    throw new Error('Please wait before requesting another link')
  }
  
  const { error } = await supabase.auth.signInWithOtp({ email })
  lastSentTime = now
  
  return { error }
}

8.2 带冷却的发送按钮 #

tsx
import { useState, useEffect } from 'react'

export function ResendButton() {
  const [countdown, setCountdown] = useState(0)

  useEffect(() => {
    if (countdown > 0) {
      const timer = setTimeout(() => setCountdown(countdown - 1), 1000)
      return () => clearTimeout(timer)
    }
  }, [countdown])

  const handleResend = async () => {
    await supabase.auth.signInWithOtp({ email })
    setCountdown(60)
  }

  return (
    <button 
      onClick={handleResend}
      disabled={countdown > 0}
    >
      {countdown > 0 ? `Resend in ${countdown}s` : 'Resend Magic Link'}
    </button>
  )
}

九、错误处理 #

9.1 常见错误 #

typescript
const errorMessages: Record<string, string> = {
  'invalid_otp': '链接已过期或无效',
  'user_not_found': '用户不存在',
  'email_not_confirmed': '请先验证邮箱',
  'rate_limit_exceeded': '请求过于频繁,请稍后再试',
}

async function handleMagicLink(email: string) {
  const { error } = await supabase.auth.signInWithOtp({ email })
  
  if (error) {
    return errorMessages[error.message] || error.message
  }
  
  return null
}

十、最佳实践 #

10.1 安全建议 #

text
魔法链接安全建议
├── 设置合理的过期时间
├── 限制发送频率
├── 使用PKCE流程
├── 记录登录日志
└── 监控异常行为

10.2 用户体验优化 #

typescript
// 保存登录前的状态
async function sendMagicLinkWithState(email: string) {
  // 保存当前路径
  sessionStorage.setItem('redirectAfterLogin', window.location.pathname)
  
  const { error } = await supabase.auth.signInWithOtp({
    email,
    options: {
      emailRedirectTo: window.location.origin + '/auth/callback',
    },
  })
  
  return { error }
}

// 登录后恢复状态
function afterLogin() {
  const redirect = sessionStorage.getItem('redirectAfterLogin') || '/dashboard'
  sessionStorage.removeItem('redirectAfterLogin')
  window.location.href = redirect
}

十一、总结 #

魔法链接要点:

操作 方法
发送链接 signInWithOtp({ email })
验证链接 verifyOtp({ token, type })
配置回调 emailRedirectTo
自定义模板 Dashboard > Email Templates

下一步,让我们学习手机号认证!

最后更新:2026-03-28