← 返回博客

在 Next.js 里做魔法登录(2026):用 HTTP 邮件 API 发链接

在 Next.js App Router 里做免密登录:应用侧生成一次性令牌,用 POST /emails 发魔法链接,核销一次建会话——安全链接建议关闭点击追踪。

更新于 2026年11月4日
在 Next.js 里做魔法登录(2026):用 HTTP 邮件 API 发链接

为什么是 Next.js + 纯 HTTP 邮件 API

Next.js App Router 已经具备免密登录需要的两块:用 Route Handler 做「给我发链接」和「核销令牌」,再用仅服务端可读的环境变量放 API Key。你不需要 Rovela SDK、NextAuth 专用 Provider,也不需要所谓「魔法登录」产品接口。

你要的是和 密码重置链接 同一套模式:应用生成一次性令牌、只存哈希、把明文令牌只放进邮件 URL,再用 POST /emails 投递。Rovela 负责入队与 DKIM 签名,不会替你建会话。

本文聚焦 魔法登录链接。若只想了解「在 App Router 里怎么发信」,见 在 Next.js 里用 Rovela API 发邮件。与框架无关的令牌配方见 魔法登录邮件链接。

应用负责什么,Rovela 负责什么

Next.js 应用负责:

  • crypto.randomBytes(32) → base64url 令牌
  • 数据库只存令牌的 SHA-256 十六进制(绝不存明文)
  • 过期时间(文档里重置类链接示例约 30 分钟)
  • 落在你自己域名上的绝对 URL:https://app.example.com/login/magic?token=…
  • 原子核销(DELETE … RETURNING)+ Session Cookie
  • 无论邮箱是否已注册,对外返回相同文案与状态

Rovela 负责:

  • POST https://api.rovela.dev/emails,Authorization: Bearer + sending_access 密钥(.env.local 里的 ROVELA_API_KEY,不要加 NEXT_PUBLIC_)
  • 入队、签名、重试、退信/投诉处理

发送接口返回 200 只表示已接受并入队,不等于已进收件箱。

令牌存储(32 字节、只存哈希、短 TTL)

// lib/magic-link.ts
import crypto from 'node:crypto'
import 'server-only'

export function createMagicToken() {
  const token = crypto.randomBytes(32).toString('base64url')
  const tokenHash = crypto.createHash('sha256').update(token).digest('hex')
  const expiresAt = new Date(Date.now() + 30 * 60 * 1000)
  return { token, tokenHash, expiresAt }
}

按用户 upsert:新请求作废旧链接,只有最新邮件有效。对 32 字节随机数,明文 SHA-256 足够(六位验证码不同,需要 HMAC + pepper,见 验证码示例)。

发送路由:app/api/auth/magic/request/route.ts

import { NextResponse } from 'next/server'
import { createMagicToken } from '@/lib/magic-link'
// import { db } from '@/lib/db'

export async function POST(req: Request) {
  const { email: raw } = await req.json()
  const email = String(raw ?? '').trim().toLowerCase()

  const user = await db.users.findByEmail(email)
  if (user) {
    const { token, tokenHash, expiresAt } = createMagicToken()
    await db.magicLogins.upsert({ userId: user.id, tokenHash, expiresAt })

    const link = `https://app.acme.com/login/magic?token=${token}`
    const res = await fetch('https://api.rovela.dev/emails', {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${process.env.ROVELA_API_KEY}`,
        'Content-Type': 'application/json',
        'Idempotency-Key': `magic-${tokenHash.slice(0, 32)}`,
      },
      body: JSON.stringify({
        from: 'Acme <security@mail.acme.com>',
        to: email,
        subject: 'Your Acme sign-in link',
        html:
          `<p>Use this link to sign in to Acme.</p>` +
          `<p><a href="${link}">Sign in</a></p>` +
          `<p>The link works once and expires in 30 minutes. ` +
          `If you did not request it, ignore this email.</p>`,
        text:
          `Use this link to sign in to Acme.\n\n` +
          `Sign in: ${link}\n\n` +
          `The link works once and expires in 30 minutes.`,
      }),
    })

    if (!res.ok) {
      console.error('rovela send failed', res.status, await res.text())
    }
  }

  return NextResponse.json({
    message: 'If that address has an account, a sign-in link is on its way.',
  })
}

务必同时发 html + text,并在纯文本里写完整 URL。认证类发件用已验证子域(如 security@mail.…)。Idempotency-Key 从令牌哈希派生,超时重试不会再入队一封;用户主动「重发」则生成新令牌 + 新 Key。

核销路由:app/api/auth/magic/redeem/route.ts

import { NextResponse } from 'next/server'
import crypto from 'node:crypto'
// import { db } from '@/lib/db'
// import { createSessionCookie } from '@/lib/session'

export async function POST(req: Request) {
  const { token } = await req.json()
  if (!token || typeof token !== 'string') {
    return NextResponse.json({ error: 'invalid_link' }, { status: 400 })
  }

  const tokenHash = crypto.createHash('sha256').update(token).digest('hex')

  const row = await db.query(
    `DELETE FROM magic_logins
     WHERE token_hash = $1 AND expires_at > NOW()
     RETURNING user_id`,
    [tokenHash]
  )

  if (row.rowCount === 0) {
    return NextResponse.json({ error: 'expired_or_used' }, { status: 400 })
  }

  const userId = row.rows[0].user_id
  const cookie = await createSessionCookie(userId)
  const res = NextResponse.json({ ok: true })
  res.headers.append('Set-Cookie', cookie)
  return res
}

页面 /login/magic 读取 ?token= 再 POST 到该 Handler。失败时统一提示「链接已过期或已使用」,并提供重新申请——不要区分未知 / 已用 / 过期,以免帮助枚举。

安全链接建议关闭点击追踪

打开/点击追踪是按域名配置的。开启点击追踪时,Rovela 会把链接改写到追踪域;登录仍可用,但令牌会多一跳。

魔法登录(以及密码重置、邀请令牌)宜用单独发信域或子域,并关闭点击追踪,让令牌直达你的应用。这与 密码重置示例 一致。若认证邮件仍进垃圾箱,对照 验证码与魔法登录为何进垃圾箱。

同一套 Next.js 里:魔法链接 vs 验证码

魔法链接OTP(六位码)
文档形态同 密码重置验证码
用户动作点一次输入数字
存储32 字节令牌的 SHA-256email:code 的 HMAC + pepper
常见 TTL约 30 分钟约 10 分钟
适合桌面/手机浏览器登录共享屏幕、二次确认

两者都走同一个 POST /emails。表与路由分开——不要把 OTP 行当魔法链接令牌复用。

上线检查清单

  1. 认证用子域已完成域名验证,SPF/DKIM/DMARC 正常。
  2. 该子域(或独立安全域)关闭点击追踪。
  3. ROVELA_API_KEY(sending_access)只放在服务端密钥里,绝不 NEXT_PUBLIC_。
  4. from 用已验证子域;html + text 齐全;纯文本含完整 URL。
  5. 每次逻辑发送带 Idempotency-Key;核销用 DELETE … RETURNING。
  6. 请求接口防枚举;对请求路由做限流。
  7. 把发送 200 当作入队;需要投递真相时接退信/投诉 Webhook。

在 Route Handler 里自己管令牌,一次 fetch 发出邮件,安全域关掉追踪——你就有了免密 Next.js 登录,而不必把邮件 API 当成身份提供商。