在 Next.js 里做魔法登录(2026):用 HTTP 邮件 API 发链接
在 Next.js App Router 里做免密登录:应用侧生成一次性令牌,用 POST /emails 发魔法链接,核销一次建会话——安全链接建议关闭点击追踪。
为什么是 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-256 | email:code 的 HMAC + pepper |
| 常见 TTL | 约 30 分钟 | 约 10 分钟 |
| 适合 | 桌面/手机浏览器登录 | 共享屏幕、二次确认 |
两者都走同一个 POST /emails。表与路由分开——不要把 OTP 行当魔法链接令牌复用。
上线检查清单
- 认证用子域已完成域名验证,SPF/DKIM/DMARC 正常。
- 该子域(或独立安全域)关闭点击追踪。
ROVELA_API_KEY(sending_access)只放在服务端密钥里,绝不NEXT_PUBLIC_。from用已验证子域;html + text 齐全;纯文本含完整 URL。- 每次逻辑发送带
Idempotency-Key;核销用 DELETE … RETURNING。 - 请求接口防枚举;对请求路由做限流。
- 把发送
200当作入队;需要投递真相时接退信/投诉 Webhook。
在 Route Handler 里自己管令牌,一次 fetch 发出邮件,安全域关掉追踪——你就有了免密 Next.js 登录,而不必把邮件 API 当成身份提供商。