← 返回博客

魔法链接安全最佳实践(2026):TTL、一次性、HTTPS、防开放重定向

无密码魔法链接安全清单:32 字节令牌、约 30 分钟 TTL、原子一次性兑换、仅 HTTPS 的 APP_ORIGIN、防开放重定向——令牌由应用持有;Rovela POST /emails 负责投递。

更新于 2026年11月4日
魔法链接安全最佳实践(2026):TTL、一次性、HTTPS、防开放重定向

魔法链接是用邮件投递的鉴权凭证

魔法链接本质上是一份临时凭证:谁打开它,谁就能建立会话。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

链接型密钥按密码重置文档做:

  1. 规范化邮箱(trim + 小写)。
  2. 生成 32 字节随机数,编成 base64url(crypto.randomBytes(32).toString("base64url"))。
  3. 只存令牌的 SHA-256 十六进制——永不存明文。32 字节无法从哈希暴力还原,明文 SHA-256 足够(短 OTP 数字则不同,需要 HMAC + pepper)。
  4. 过期约 30 分钟,与邮件文案里的数字保持同一常量。
  5. 明文令牌只出现在邮件 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 泄露。

硬性规则:

  1. 代码里写死主机白名单。 scheme + host(通常还有路径前缀)来自服务端配置常量(APP_ORIGIN=https://app.acme.com),不是请求体或查询串。
  2. 兑换路由禁止开放重定向。 成功后跳到固定登录后页,或只允许同站相对路径(/dashboard、/settings)。拒绝指向其他主机的绝对 URL;拒绝协议相对的 //evil.example/...。
  3. 除非是你控制的固定路径常量,否则不要把登录后目的地写进邮件链接。 更优:邮件只含 token;由兑换处理器决定导航。
  4. 校验兑换请求的 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 + pepper32 字节令牌的 SHA-256
开放重定向面低(用户手输数字)高(若 URL 主机/路径可被攻击者影响)
共同点同一 POST /emails 投递路径同一

验证码校验与链接兑换保持分表分路由。决策框架见验证码 vs 链接。

本文不是什么

  • 不是框架教程(见博客上 NestJS / FastAPI / Remix / SvelteKit / Astro / Rails / Next.js 魔法链接文)。
  • 不是密码重置深潜(安全的密码重置邮件)。
  • 不是声称 Rovela 会校验令牌、强制 TTL 或防止开放重定向——那些是应用侧控制。Rovela 投递你提交的消息。
  • 不是虚构的大小限制、MCP 工具或中国境内 ESP 话题。

下一步

  1. 按密码重置示例实现令牌与兑换;落地页改为创建会话。
  2. 锁定 APP_ORIGIN,禁止兑换开放重定向,并为安全子域关闭点击追踪。
  3. 用已验证域名 + POST /emails + Idempotency-Key 发送(Quick Start)。
  4. 产品向导见魔法登录邮件链接。

**一句话:**把每条魔法链接当成短命密码。熵、TTL、一次性、HTTPS 与防开放重定向由你的应用负责;Rovela 负责把你已经做安全的 URL 可靠投递出去。