魔法链接安全最佳实践(2026):TTL、一次性、HTTPS、防开放重定向
无密码魔法链接安全清单:32 字节令牌、约 30 分钟 TTL、原子一次性兑换、仅 HTTPS 的 APP_ORIGIN、防开放重定向——令牌由应用持有;Rovela POST /emails 负责投递。
魔法链接是用邮件投递的鉴权凭证
魔法链接本质上是一份临时凭证:谁打开它,谁就能建立会话。Rovela 不是身份提供方,也不会签发登录令牌。由你的应用生成密钥、以哈希+过期时间落库、在你自己的源站上拼绝对 HTTPS URL,再调用 POST /emails,由 Rovela 排队、DKIM 签名并投递。官方最接近的配方是密码重置链接——魔法登录是同一套令牌形状,兑换路由改成创建会话而不是改密码。产品向导见魔法登录邮件链接;本文是安全清单(TTL、一次性、HTTPS、防开放重定向),避免邮件 API 变成账号接管通道。
边界:应用管令牌,Rovela 管投递
| 职责 | 归属 |
|---|---|
| 密码学令牌、哈希存储、TTL、一次性兑换 | 你的应用 |
绝对链接的主机/路径(https://app.example.com/...) | 你的应用 |
| 兑换后的会话 cookie / JWT | 你的应用 |
| 限流、防枚举响应 | 你的应用 |
接受 POST https://api.rovela.dev/emails、排队、签名、重试、退信/投诉 | Rovela |
只用后端持有 Bearer re_… 密钥调用 Rovela。生产发送优先 sending_access;切勿把 API key 放进浏览器或移动客户端(Authentication)。套餐与配额见Pricing——本文不编造数字。
令牌熵、哈希存储与 TTL
链接型密钥按密码重置文档做:
- 规范化邮箱(
trim+ 小写)。 - 生成 32 字节随机数,编成
base64url(crypto.randomBytes(32).toString("base64url"))。 - 只存令牌的 SHA-256 十六进制——永不存明文。32 字节无法从哈希暴力还原,明文 SHA-256 足够(短 OTP 数字则不同,需要 HMAC + pepper)。
- 过期约 30 分钟,与邮件文案里的数字保持同一常量。
- 明文令牌只出现在邮件 URL 查询串——不要进日志、分析或数据库。
示意(占位;完整发送见密码重置示例):
import crypto from "node:crypto"
const token = crypto.randomBytes(32).toString("base64url")
const tokenHash = crypto.createHash("sha256").update(token).digest("hex")
await db.magicLogins.upsert({
userId: user.id,
tokenHash,
expiresAt: new Date(Date.now() + 30 * 60 * 1000),
})
更短 TTL(如 10–15 分钟)缩小邮箱被盗窗口;更长则增加客服量。无论选多久,过期必须在兑换查询里强制,不能只写在文案里。
并发下的一次性
先 SELECT 再 DELETE 会丢一次性:两次并行点击都能通过读取。按文档用一条语句花掉令牌:
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 null // 过期、未知或已使用
// 为 row.rows[0].user_id 创建会话
失败时展示通用的「链接已过期或已使用」。不要区分未知/过期/已花掉,以免帮助攻击者。为同一用户签发新令牌时,作废尚未使用的旧令牌,避免线程里更早的邮件仍能登录。
仅 HTTPS、且落在你的源站
- 自己拼链接:
https://app.acme.com/login/magic?token=+ token(必要时encodeURIComponent(token))。 - 生产强制 HTTPS。
http://魔法链接等于把凭证交给网络中间人。 - 令牌放在查询串,不要放在 URL fragment。Fragment 不会到服务器,服务端兑换读不到
#token=…。 - 始终同时发
html与text,并在纯文本里放完整 URL——企业过滤器常会剥掉 HTML 链接(密码重置示例)。
防开放重定向(勿把攻击者控制的主机写进邮件)
邮件里的 URL 绝不能由不可信的 next、redirect、return_to、continue 查询参数拼出来。若攻击者能让你发出 https://evil.example/phish?token=…(或兑换后跳到站外),事务邮件就变成钓鱼投递系统——令牌还可能从地址栏或 Referer 泄露。
硬性规则:
- 代码里写死主机白名单。 scheme + host(通常还有路径前缀)来自服务端配置常量(
APP_ORIGIN=https://app.acme.com),不是请求体或查询串。 - 兑换路由禁止开放重定向。 成功后跳到固定登录后页,或只允许同站相对路径(
/dashboard、/settings)。拒绝指向其他主机的绝对 URL;拒绝协议相对的//evil.example/...。 - 除非是你控制的固定路径常量,否则不要把登录后目的地写进邮件链接。 更优:邮件只含
token;由兑换处理器决定导航。 - 校验兑换请求的 Origin/Host,标准与任何创建会话的 POST 相同。
错误:
// 攻击者控制的主机进了收件箱
const link = req.query.next + "?token=" + token
正确:
const APP_ORIGIN = "https://app.acme.com" // 配置,非用户输入
const link = APP_ORIGIN + "/login/magic?token=" + token
function safeInternalPath(candidate) {
if (typeof candidate !== "string" || !candidate.startsWith("/")) return "/dashboard"
if (candidate.startsWith("//") || candidate.includes("://")) return "/dashboard"
return candidate.split("?")[0] // 可选:去掉 query;仍仅同站
}
防枚举的请求处理
无论地址是否有账号,都返回相同文案与状态码。理想情况下把发送入队后立即返回,消除时序差异(密码重置示例)。按地址与 IP 限流「给我发链接」,避免产品被人拿去轰炸别人的收件箱。
发送路径:Idempotency-Key、子域、关闭追踪
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.</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.",
}),
})
文档可核对的发送细节(API: emails、密码重置示例):
Idempotency-Key最长 256 字符;24 小时内重试返回原始响应而不再发一封。用该次逻辑发送的令牌哈希稳定前缀派生。用户点「重发」是新的逻辑发送——新令牌、新 key。- 使用已验证的、专用于安全邮件的发送子域(如
security@mail.acme.com)。未验证的from域名会收到400。 - 在该域上关闭点击追踪。追踪会把链接改写到追踪主机;登录 URL 仍可用,但令牌多一跳。安全邮件应直达你的应用(密码重置示例)。投递背景:OTP 与魔法链接进垃圾箱。
200表示已接受并排队,不是「已经进收件箱」。若关心鉴权邮件不可达,接退信/投诉 Webhook(Webhooks)。
常见安全错误
| 错误 | 风险 | 修复 |
|---|---|---|
| 明文存令牌 | 库泄露=大规模接管 | 只存 SHA-256 哈希 |
| 无 TTL / TTL 过长 | 被盗邮箱长期可用 | ~30 分钟,SQL 强制 |
| 先读后删兑换 | 并发双兑 | DELETE … RETURNING |
邮件里 http:// 或错误主机 | 中间人 / 钓鱼 | 仅用配置的 HTTPS APP_ORIGIN |
邮件 URL 带用户提供的 redirect= | 开放重定向 + 钓鱼 | 兑换后只允许同站路径白名单 |
| 鉴权域开点击追踪 | 令牌经额外跳转 | 独立子域并关追踪 |
| 「用户不存在」文案不同 | 账号枚举 | 相同响应 + 时序 |
| API key 在客户端 | 密钥被盗可任意发信 | 仅服务端 sending_access |
重试无 Idempotency-Key | 两个有效链接 / 体验混乱 | 用令牌哈希前缀作 key |
| 只有 HTML 正文 | 过滤器剥掉唯一链接 | 始终带含完整 URL 的 text |
OTP vs 魔法链接(安全视角)
| OTP 验证码 | 魔法链接 | |
|---|---|---|
| 文档配方 | 验证码 | 与密码重置同形发送 |
| 文档示例典型过期 | ~10 分钟 | ~30 分钟 |
| 存储 | 短码需 HMAC + pepper | 32 字节令牌的 SHA-256 |
| 开放重定向面 | 低(用户手输数字) | 高(若 URL 主机/路径可被攻击者影响) |
| 共同点 | 同一 POST /emails 投递路径 | 同一 |
验证码校验与链接兑换保持分表分路由。决策框架见验证码 vs 链接。
本文不是什么
- 不是框架教程(见博客上 NestJS / FastAPI / Remix / SvelteKit / Astro / Rails / Next.js 魔法链接文)。
- 不是密码重置深潜(安全的密码重置邮件)。
- 不是声称 Rovela 会校验令牌、强制 TTL 或防止开放重定向——那些是应用侧控制。Rovela 投递你提交的消息。
- 不是虚构的大小限制、MCP 工具或中国境内 ESP 话题。
下一步
- 按密码重置示例实现令牌与兑换;落地页改为创建会话。
- 锁定
APP_ORIGIN,禁止兑换开放重定向,并为安全子域关闭点击追踪。 - 用已验证域名 +
POST /emails+Idempotency-Key发送(Quick Start)。 - 产品向导见魔法登录邮件链接。
**一句话:**把每条魔法链接当成短命密码。熵、TTL、一次性、HTTPS 与防开放重定向由你的应用负责;Rovela 负责把你已经做安全的 URL 可靠投递出去。