← 返回博客

SvelteKit 魔法登录链接(2026):用 HTTP 邮件 API 发信

在 SvelteKit 里做无密码魔法登录:form actions 与 +server 路由、token 哈希落库、仅在 POST redeem 写 cookie session、fetch 调 POST /emails 并带 Idempotency-Key——Rovela 只负责投递。

更新于 2026年11月4日
SvelteKit 魔法登录链接(2026):用 HTTP 邮件 API 发信

为什么用 SvelteKit + 纯 HTTP 邮件 API

SvelteKit 做无密码登录本来就很顺:一个可渐进增强的 <form method="POST">、在 +page.server.ts 的 form action 里发 token 并 upsert、以及一个 load 只读 ?token=、再用第二个 form action(或小型 +server.ts POST)核销并写入 httpOnly session cookie 的验证页。你不需要 Rovela 的 SvelteKit 插件,也不需要名叫「魔法链接」的产品接口,更不必照搬 Remix 那套成对的 loader/action 导出。

你需要的是和 密码重置链接 同一套 token 配方:应用用 crypto.randomBytes 生成一次性 token,只存 SHA-256,明文只出现在邮件 URL 里,再用 fetch POST /emails 投递。Rovela 负责排队与 DKIM 签名;它不会签发 session,也不会校验 token。

Rovela 不提供魔法链接功能。 密码学、数据库行、过期、单次核销、cookie 都归你的 SvelteKit 应用。Rovela 只负责投递。

本文聚焦 SvelteKit / TypeScript(+page.server.ts 的 actions、可选 +server.ts、$env/dynamic/private、cookies.set、Node crypto、服务端 fetch)。它不是 Remix 魔法登录(Remix loader/action)的复读,也不是 Next.js 魔法登录(App Router Route Handlers / Server Actions)。Nest 见 NestJS 魔法登录;FastAPI 见 FastAPI 魔法登录;Rails 见 Rails 魔法登录。与框架无关的配方见 魔法登录邮件链接;验证码 vs 链接选型见 邮箱验证:验证码还是链接。

你负责什么,Rovela 发什么

你的 SvelteKit 应用负责:

  • crypto.randomBytes(32).toString("base64url") 作为明文 token
  • 数据库只存 token 的 SHA-256 十六进制(永不存明文)
  • 过期时间(文档示例对重置类链接约 30 分钟)
  • 你自己源站上的绝对 URL:https://app.example.com/auth/magic?token=…
  • 原子核销(删除并返回)+ cookies.set 写 session
  • 不论邮箱是否注册,action 对外响应保持一致
  • 对 request / redeem action 做限流

Rovela 负责:

  • POST https://api.rovela.dev/emails,Authorization: Bearer + 具备 sending_access 的密钥(ROVELA_API_KEY 经 $env/dynamic/private 或进程环境变量——不要提交进仓库,也不要放进 $env/static/public 或任何会进浏览器的 Vite 环境变量)
  • 排队、签名、重试、退信/投诉处理

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

环境变量与精简 Rovela 客户端(fetch)

密钥放在只有服务端模块能读的私有环境变量里:

// src/lib/server/env.ts
import { env } from "$env/dynamic/private";

export function requireEnv(name: string): string {
  const v = env[name] ?? process.env[name];
  if (!v) throw new Error(`missing_env:${name}`);
  return v;
}

// ROVELA_API_KEY=re_...          // sending_access
// MAGIC_FROM=Acme <security@mail.acme.com>
// MAGIC_LINK_BASE_URL=https://app.example.com
// MAGIC_TTL_MINUTES=30
// SESSION_SECRET=long-random-string
// src/lib/server/rovela.ts
import { requireEnv } from "./env";

export async function sendEmail(opts: {
  to: string;
  subject: string;
  html: string;
  text: string;
  idempotencyKey: string;
}) {
  const apiKey = requireEnv("ROVELA_API_KEY"); // sending_access
  const from = process.env.MAGIC_FROM ?? "Acme <security@mail.acme.com>";

  const res = await fetch("https://api.rovela.dev/emails", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${apiKey}`,
      "Content-Type": "application/json",
      Accept: "application/json",
      "Idempotency-Key": opts.idempotencyKey.slice(0, 256),
    },
    body: JSON.stringify({
      from,
      to: opts.to,
      subject: opts.subject,
      html: opts.html,
      text: opts.text,
    }),
  });

  if (!res.ok) {
    // 不要打印 API 密钥。
    console.error("rovela_send_failed", res.status);
    throw new Error("send_failed");
  }
  return (await res.json()) as { object: string; id: string };
}

鉴权见 Authentication,报文见 API: emails,首次发送见 Quick Start。

Session cookie(SvelteKit cookies)

SvelteKit 不强制 Remix 那套 createCookieSessionStorage。本配方用签名或不透明 session id 即可——若用随机 id,把映射存服务端:

// src/lib/server/session.ts
import type { Cookies } from "@sveltejs/kit";
import crypto from "node:crypto";

const COOKIE = "__acme_session";

export function setSessionCookie(cookies: Cookies, userId: string) {
  // 优先把随机 session id 落到数据库;直接嵌 userId 仅作示意。
  const sessionId = crypto.randomBytes(32).toString("base64url");
  // await db.session.create({ data: { id: sessionId, userId, ... } });
  void userId;
  cookies.set(COOKIE, sessionId, {
    path: "/",
    httpOnly: true,
    sameSite: "lax",
    secure: process.env.NODE_ENV === "production",
    maxAge: 60 * 60 * 24 * 14,
  });
  return sessionId;
}

Token 助手:只存哈希,短 TTL

// src/lib/server/magic-token.ts
import crypto from "node:crypto";

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

export function hashToken(token: string) {
  return crypto.createHash("sha256").update(token).digest("hex");
}

按用户 upsert,使只有最新未用链接生效。高熵 URL-safe token 用纯 SHA-256 即可(六位 OTP 不同,需要 HMAC + pepper——见 验证码示例)。

Service:创建、落库、发送

// src/lib/server/magic.ts
import { createMagicToken, hashToken } from "./magic-token";
import { sendEmail } from "./rovela";
import { requireEnv } from "./env";

const SAFE_MSG =
  "If that address has an account, a sign-in link is on its way.";

export async function requestMagicLink(rawEmail: string) {
  const email = rawEmail.trim().toLowerCase();
  const user = await findUserByEmail(email); // 你的存储

  if (user) {
    const ttl = Number(process.env.MAGIC_TTL_MINUTES ?? "30");
    const { token, tokenHash, expiresAt } = createMagicToken(ttl);

    await upsertMagicLogin(user.id, tokenHash, expiresAt);

    const base = requireEnv("MAGIC_LINK_BASE_URL").replace(/\/$/, "");
    const link = `${base}/auth/magic?token=${token}`;

    await sendEmail({
      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.`,
      idempotencyKey: `magic-${tokenHash.slice(0, 32)}`,
    });
  }

  // 不论账号是否存在,返回相同文案。
  return { message: SAFE_MSG };
}

export async function redeemMagicToken(token: string) {
  if (!token || token.length < 16) return null;
  const tokenHash = hashToken(token);
  // 优先 DELETE … WHERE token_hash = $1 AND expires_at > NOW() RETURNING user_id
  return await consumeMagicLogin(tokenHash);
}

// 占位——换成你的数据库:
declare function findUserByEmail(email: string): Promise<{ id: string } | null>;
declare function upsertMagicLogin(
  userId: string,
  tokenHash: string,
  expiresAt: Date,
): Promise<void>;
declare function consumeMagicLogin(tokenHash: string): Promise<string | null>;

务必同时发 html 与 text,并在纯文本里带完整 URL。鉴权邮件用已验证子域(如 security@mail.…)。Idempotency-Key 由 token 哈希派生,超时重试不会再入队一封;真正「重发」应换新 token、新 key。

Form action:请求链接(+page.server.ts)

// src/routes/auth/magic/request/+page.server.ts
import type { Actions } from "./$types";
import { fail } from "@sveltejs/kit";
import { requestMagicLink } from "$lib/server/magic";

export const actions: Actions = {
  default: async ({ request }) => {
    // 同时按邮箱 + IP 限流(Redis / Upstash / 你的限流器)。
    const form = await request.formData();
    const email = String(form.get("email") ?? "");
    if (!email.includes("@")) {
      return fail(400, { error: "invalid_email" });
    }
    const payload = await requestMagicLink(email);
    return payload; // { message }
  },
};
<!-- src/routes/auth/magic/request/+page.svelte -->
<script lang="ts">
  import { enhance } from "$app/forms";
  import type { ActionData } from "./$types";
  export let form: ActionData;
</script>

<form method="POST" use:enhance>
  <label>
    Work email
    <input name="email" type="email" required autocomplete="email" />
  </label>
  <button type="submit">Email me a sign-in link</button>
  {#if form?.message}<p>{form.message}</p>{/if}
  {#if form?.error}<p>{form.error}</p>{/if}
</form>

form action 在服务端执行,Rovela 密钥不会离开该进程。优先用可渐进增强的 <form method="POST">(可加 use:enhance),无客户端 JS 也能用。若 SPA 岛需要 JSON API,再加一个调用同一 requestMagicLink 助手的 +server.ts——不要复制一套密码学逻辑。

验证页:load 展示 token,action 核销(POST)

优先用 POST redeem,不要在 GET 上直接建 session:邮件扫描器和预取器常会打 GET。

// src/routes/auth/magic/+page.server.ts
import type { Actions, PageServerLoad } from "./$types";
import { fail, redirect } from "@sveltejs/kit";
import { redeemMagicToken } from "$lib/server/magic";
import { setSessionCookie } from "$lib/server/session";

export const load: PageServerLoad = async ({ url }) => {
  const token = url.searchParams.get("token") ?? "";
  // 不要在这里建 session——扫描器会预取 GET。
  return { token };
};

export const actions: Actions = {
  default: async ({ request, cookies }) => {
    const form = await request.formData();
    const token = String(form.get("token") ?? "");
    const userId = await redeemMagicToken(token);
    if (!userId) {
      return fail(400, { error: "expired_or_used" });
    }
    setSessionCookie(cookies, userId);
    throw redirect(303, "/app");
  },
};
<!-- src/routes/auth/magic/+page.svelte -->
<script lang="ts">
  import { enhance } from "$app/forms";
  import type { ActionData, PageData } from "./$types";
  export let data: PageData;
  export let form: ActionData;
</script>

<h1>Continue sign-in</h1>
<p>Click once to finish signing in. This step stops email scanners from burning the link.</p>
<form method="POST" use:enhance>
  <input type="hidden" name="token" value={data.token} />
  <button type="submit" disabled={!data.token}>Continue</button>
</form>
{#if form?.error}<p>This link expired or was already used.</p>{/if}

可选:用 +server.ts 做 JSON 核销

有些客户端更想 fetch JSON,而不是表单 POST。复用同一 redeem 助手——不要在 GET 上核销:

// src/routes/auth/magic/redeem/+server.ts
import { json, error } from "@sveltejs/kit";
import type { RequestHandler } from "./$types";
import { redeemMagicToken } from "$lib/server/magic";
import { setSessionCookie } from "$lib/server/session";

export const POST: RequestHandler = async ({ request, cookies }) => {
  const body = (await request.json().catch(() => null)) as { token?: string } | null;
  const token = body?.token ?? "";
  const userId = await redeemMagicToken(token);
  if (!userId) throw error(400, "expired_or_used");
  setSessionCookie(cookies, userId);
  return json({ ok: true });
};

若是邮箱归属确认(不是登录),同一 service 形状即可,换表与路径——见 邮箱验证链接。密码重置是文档里的孪生场景:安全的密码重置邮件 与 密码重置示例。

登录 URL 请关闭点击追踪

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

对魔法链接(以及密码重置 / 邀请 token),建议单独的发送域或子域,并关闭点击追踪,让 token 直达你的 SvelteKit 验证路由。这与 密码重置示例 的建议一致。若鉴权邮件仍进垃圾箱,按 OTP / 魔法链接进垃圾箱 排查。

限流 / 单次 / 过期清单

  1. 发放限流 — 例如同一邮箱每分钟一条新链接;request action 同时按 IP 封顶。
  2. 短 TTL — 登录约 15–30 分钟;二次验证可以更短。
  3. 落库只存哈希 — 仅 SHA-256 十六进制;明文只在邮件 URL。
  4. Upsert — 新请求作废该用户上一条未用哈希。
  5. 原子核销 — DELETE … RETURNING(或加锁删除),双击竞争安全失败。
  6. 防枚举 request — 不论地址是否存在,状态码与正文一致。
  7. Idempotency-Key — 每次逻辑发送稳定(magic- + 哈希前缀);新 token → 新 key。
  8. 防预取 redeem — load 可读取 ?token=;只有 POST action / +server.ts 才建 session。

curl 等价(代理 / 冒烟)

与 SvelteKit 服务端发出的报文相同——便于本地冒烟,也方便直接调 HTTP 的 AI agent(AI agent 发邮件):

curl -X POST https://api.rovela.dev/emails \
  -H "Authorization: Bearer re_your_sending_key" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: magic-smoke-2026-11-05" \
  -d '{
    "from": "Acme <security@mail.acme.com>",
    "to": "alice@example.com",
    "subject": "Your Acme sign-in link",
    "html": "<p><a href=\"https://app.acme.com/auth/magic?token=TEST\">Sign in</a></p><p>Expires in 30 minutes.</p>",
    "text": "Sign in: https://app.acme.com/auth/magic?token=TEST\n\nExpires in 30 minutes."
  }'

仅在非生产环境把 TEST 换成真实 token。生产密钥需具备 sending_access;from 必须落在已验证域名上(Quick Start)。

SvelteKit vs Remix vs Next.js(避免混淆)

本文(SvelteKit)Remix 魔法登录Next.js 魔法登录
服务端入口+page.server.ts 的 actions / load;可选 +server.ts路由 action / loaderApp Router Route Handlers / Server Actions
Sessioncookies.set(+ 你的存储)Remix cookie session storageNext cookies / 你的鉴权库
UI 表单渐进增强 <form method="POST"> + use:enhance渐进增强 Remix <Form method="post">Server Actions / 客户端 fetch
重点SvelteKit 无密码登录Remix 无密码登录Next 无密码登录

三者都调同一个 POST /emails。不要把 SvelteKit form action 和 Cloudflare Email Workers 混为一谈(Workers 事务邮件)。

同一 SvelteKit 应用里:魔法链接 vs OTP

魔法链接OTP(六位数字)
文档形状同 密码重置验证码
SvelteKit 位置/auth/magic + /auth/magic/request独立 OTP 路由 / action
体验一点即登(+ Continue)输入数字
存储高熵 token 的 SHA-256email:code 的 HMAC + pepper
常见 TTL~30 分钟~10 分钟
适合桌面/手机浏览器登录共享屏幕、二次验证

两者都调同一个 POST /emails。表要分开——不要把 OTP 行当魔法链接 token 用。产品选型:验证码 vs 链接。偏 Hono 边缘 OTP 见 Hono OTP 邮件 API。

上线清单

  1. 域名已验证;鉴权子域 SPF/DKIM/DMARC 正常。
  2. 该子域(或独立安全域)关闭点击追踪。
  3. 仅具备 sending_access 的 ROVELA_API_KEY 放在私有服务端环境——不要进 $env/static/public。
  4. MAGIC_FROM 用已验证子域;html + text 都有;纯文本含完整 URL。
  5. 每次逻辑发送带 Idempotency-Key;redeem 为删除并返回。
  6. request 防枚举;request(与 redeem)限流。
  7. 把发送 200 当作已入队;若需投递真相,用 webhook 看退信/投诉(Webhooks)。

本文不是什么

  • 不是完整生产级鉴权框架(Lucia Auth 深潜、OAuth2、MFA)。
  • 不是 Remix、Next.js、NestJS、FastAPI、Rails 或 Workers 的重复。
  • 不是声称 Rovela 会存储、校验或过期你的 token。
  • 不是国内 ESP 对比,也不捏造产品功能。

下一步

  1. 按 密码重置示例 实现 token + SvelteKit form action。
  2. 用 POST /emails · Quick Start · Authentication 发送。
  3. 投递加固:OTP / 魔法链接进垃圾箱。
  4. UX 选型:验证码 vs 链接。
  5. 姊妹栈:Remix · Next.js · NestJS · FastAPI · Rails。
  6. 额度:Pricing。

一句话: 把魔法登录放进 SvelteKit 的 form action:用 crypto.randomBytes 生成 token 并哈希,只在 POST redeem 时写入 cookie session,再对 Rovela fetch 一次 POST /emails——安全域关闭点击追踪,也永远不要把邮件 API 当成身份提供方。