Next.js 使用 Magic 实现无密码邮箱魔法链接认证的完整实战指南
引言导读
本文围绕 Next.js 官方示例 examples/with-magic 展开,讲解如何将 Magic(基于 DID Token 的无密码邮箱魔法链接认证服务)接入 Next.js 应用,构建一套「服务端签发加密会话 Cookie、前端通过 SWR 消费用户信息、API Route 完成登录/登出」的完整闭环。读完本文你将掌握:基于邮箱魔法链接的无密码登录端到端流程、用 @hapi/iron 对会话做服务端加密与过期校验的技巧、httpOnly Cookie 的安全属性配置,以及 useUser 钩子驱动的路由守卫模式——这套骨架同样适用于需要接入任意第三方认证或自建会话体系的项目。
核心流程:无密码认证是如何工作的
该示例不包含任何密码输入框,用户只需填写邮箱即可完成登录,其核心依赖两条链路:
- Magic 托管链路:用户在浏览器端通过
magic-sdk初始化客户端 SDK,调用magic.auth.loginWithMagicLink({ email })。Magic 服务会向该邮箱发送一封「魔法链接」,用户点击后,客户端 SDK 拿到一个一次性的 DID Token(Decentralized ID Token)。 - 应用自有链路:前端把 DID Token 通过
Authorization: Bearer <didToken>头发送给 Next.js API Route;服务端用@magic-sdk/admin的MAGIC_SECRET_KEY校验并换取用户元数据(issuer、email 等),随后创建本地会话,将经过 @hapi/iron 密封加密的 Cookie 写回浏览器。
后续每个受保护请求(如 /api/user)只依赖那张 httpOnly Cookie,服务端解开密封并校验过期时间后即可识别用户,全程不再与 Magic 交互。这样一个精心设计的好处是:密码永远不落地,登录凭证是一次性、由服务端独立验证的。
整个示例的目录结构非常清晰,可以按「会话基础层 → 服务端 API → 前端页面」三层阅读:
- 会话基础层:lib/auth-cookies.js、lib/auth.js、lib/magic.js、lib/hooks.js
- 服务端 API:pages/api/login.js、pages/api/logout.js、pages/api/user.js
- 前端页面:pages/login.js、pages/index.js、pages/profile.js,以及 components/form.js 等展示组件
快速开始:三种方式拉起示例应用
与其他 Next.js 示例一致,可以用 create-next-app 通过 --example with-magic 直接引导(bootstrap)整个项目,无需手工拷贝文件。官方 README 同时给出了 npm、Yarn、pnpm 三种写法:
npx create-next-app --example with-magic with-magic-app
yarn create next-app --example with-magic with-magic-app
pnpm create next-app --example with-magic with-magic-app
命令执行完毕后会生成名为 with-magic-app 的目录,进入目录即可按下一节的配置步骤操作。值得说明的是,该示例并不强制依赖某个特定 Next.js 版本——package.json 中 next 使用 "latest",而其余关键依赖均锁定版本:@hapi/iron@6.0.0、@magic-sdk/admin@1.0.0、magic-sdk@1.0.1、cookie@0.5.0、swr@^2.0.0,并基于 React 18(react@^18.2.0)。
配置 Magic:获取密钥与环境变量
获取两组密钥
登录 Magic 控制台(Dashboard),为应用创建凭证,你将得到两组形式固定的密钥:
- 发布密钥(Publishable Key):形如
pk_test_abc(测试)或pk_live_ABC(生产),会被打包进浏览器端代码,因此变量名必须带NEXT_PUBLIC_前缀。 - 密钥(Secret Key):形如
sk_test_ABC或sk_live_ABC,只允许出现在 Node.js 服务端环境,绝不能泄漏到客户端 bundle。
复制并填写 .env.local
在项目目录中把环境变量模板复制为 Git 会忽略的本地文件:
cp .env.local.example .env.local
.env.local.example 的完整内容如下:
NEXT_PUBLIC_MAGIC_PUBLISHABLE_KEY=
MAGIC_SECRET_KEY=
TOKEN_SECRET="this-is-a-secret-value-with-at-least-32-characters"
三个变量各司其职:
| 环境变量 | 作用域 | 取值要求 | 用途 |
|---|---|---|---|
NEXT_PUBLIC_MAGIC_PUBLISHABLE_KEY |
浏览器 + 服务端构建 | 形如 pk_test_abc / pk_live_ABC |
pages/login.js 中 new Magic(...) 实例化客户端 SDK 时使用 |
MAGIC_SECRET_KEY |
仅服务端 | 形如 sk_test_ABC / sk_live_ABC |
lib/magic.js 初始化 Admin SDK 验证 DID Token |
TOKEN_SECRET |
仅服务端 | 至少 32 个字符的随机字符串 | 作为 @hapi/iron 的密封密钥,加密会话 Cookie |
从源码可清楚看到密钥的使用方式:客户端在 pages/login.js 读取 process.env.NEXT_PUBLIC_MAGIC_PUBLISHABLE_KEY;而 lib/magic.js 中 Admin SDK 的单例只有两行:
const { Magic } = require("@magic-sdk/admin");
export const magic = new Magic(process.env.MAGIC_SECRET_KEY);
NEXT_PUBLIC_ 前缀在此是决定性的:Next.js 在编译期会把以它为前缀的变量内联到浏览器 bundle,而 MAGIC_SECRET_KEY 与 TOKEN_SECRET 只存在于服务端运行时环境,两者一旦写反就会导致密钥暴露或验证失败。
配置完成后启动开发服务器:
npm run dev
# 或
yarn dev
# 或
pnpm dev
应用默认运行在 http://localhost:3000,首页会给出三步体验指引:点击 Login 输入邮箱 → 登录后进入 Profile 观察 Cookie 中的会话如何被使用 → 点击 Logout 后再访问 Profile 会被重定向回 Login。
会话层深度解析:加密 Cookie 与过期校验
这一层是全示例安全性的核心,由两个文件组成。
会话 Cookie 的序列化与安全属性
lib/auth-cookies.js 定义了 Cookie 的名字与过期时长:
import { serialize, parse } from "cookie";
const TOKEN_NAME = "token";
export const MAX_AGE = 60 * 60 * 8; // 8 hours
export function setTokenCookie(res, token) {
const cookie = serialize(TOKEN_NAME, token, {
maxAge: MAX_AGE,
expires: new Date(Date.now() + MAX_AGE * 1000),
httpOnly: true,
secure: process.env.NODE_ENV === "production",
path: "/",
sameSite: "lax",
});
res.setHeader("Set-Cookie", cookie);
}
逐一拆解这些选项对安全的含义:
httpOnly: true:Cookie 无法被浏览器 JavaScript 读取,杜绝了 XSS 窃取会话令牌的可能——这正是 README 中强调的「登录 Cookie 只能被 API 访问」的机制来源。secure: process.env.NODE_ENV === "production":在生产环境强制要求 HTTPS 传输;开发环境(localhost 明文 HTTP)则放行,方便本地调试。sameSite: "lax":缓解部分 CSRF 场景。maxAge/expires:双写 8 小时有效期(60 * 60 * 8秒)。path: "/":全站生效。
文件还导出了清除 Cookie 的 removeTokenCookie(通过 maxAge: -1 让浏览器立即失效),以及一套兼容 API Route 与页面两种上下文的解析工具:
export function parseCookies(req) {
// For API Routes we don't need to parse the cookies.
if (req.cookies) return req.cookies;
// For pages we do need to parse the cookies.
const cookie = req.headers?.cookie;
return parse(cookie || "");
}
这里体现了 Next.js 的一个重要实现差异:API Route 请求对象自带解析好的 req.cookies,而页面级 getServerSideProps 里则没有,因此需要从 req.headers.cookie 原始字符串手动用 cookie 包解析。getTokenCookie 最终从解析结果中取出名为 token 的 Cookie 值。
用 Iron 密封会话并在请求侧解封校验
lib/auth.js 负责会话的「加密写入」与「解密读取」,其设计把「登录状态」封装成服务端可独立验证的密封对象,而不是仅仅依赖 Cookie 的 maxAge:
import Iron from "@hapi/iron";
import { MAX_AGE, setTokenCookie, getTokenCookie } from "./auth-cookies";
const TOKEN_SECRET = process.env.TOKEN_SECRET;
export async function setLoginSession(res, session) {
const createdAt = Date.now();
// Create a session object with a max age that we can validate later
const obj = { ...session, createdAt, maxAge: MAX_AGE };
const token = await Iron.seal(obj, TOKEN_SECRET, Iron.defaults);
setTokenCookie(res, token);
}
export async function getLoginSession(req) {
const token = getTokenCookie(req);
if (!token) return;
const session = await Iron.unseal(token, TOKEN_SECRET, Iron.defaults);
const expiresAt = session.createdAt + session.maxAge * 1000;
// Validate the expiration date of the session
if (Date.now() > expiresAt) {
throw new Error("Session expired");
}
return session;
}
关键实现要点:
setLoginSession在写入前,把签发时刻createdAt与最大存活时长maxAge一并并入会话对象,再用Iron.seal(obj, TOKEN_SECRET, Iron.defaults)加密成一段不透明字符串。因此即便攻击者拿到 Cookie 原文,也无法在不知道TOKEN_SECRET的情况下篡改email、issuer等字段。getLoginSession反向用Iron.unseal解密,并在服务端重新计算过期时间:expiresAt = createdAt + maxAge * 1000,一旦超过即抛Session expired。这意味着会话的生死由「密封内容中的时间戳」决定,而非依赖客户端可能伪造的 Cookie 属性,过期判断是可信的服务端逻辑。- 该文件把
TOKEN_SECRET从模块顶层读取,也就是 .env.local 中那句「至少 32 字符」注释对应的要求——iron 的默认加密方案要求足够长且高熵的密码。
服务端 API:登录、登出与取用户
登录接口:验证 DID Token 并写入会话
pages/api/login.js 是整个认证链路的服务端入口:
import { magic } from "../../lib/magic";
import { setLoginSession } from "../../lib/auth";
export default async function login(req, res) {
try {
const didToken = req.headers.authorization.slice(7);
const metadata = await magic.users.getMetadataByToken(didToken);
const session = { ...metadata };
await setLoginSession(res, session);
res.status(200).send({ done: true });
} catch (error) {
res.status(error.status || 500).end(error.message);
}
}
其中 req.headers.authorization.slice(7) 是在截掉 "Bearer " 前缀后取出真正的 DID Token。随后 magic.users.getMetadataByToken(didToken) 由 Admin SDK 向 Magic 服务端校验该一次性 Token,校验通过后返回用户元数据(通常包含 issuer、email、phoneNumber 等),这段元数据被整体作为会话主体 session。只有走到这里,Magic 才真正「认账」;任何伪造 Token 都会在校验阶段抛错并落入 catch,以 error.status(或 500)结束响应。
登出接口:双端联动注销
pages/api/logout.js 展示了登出时「Magic 侧 + 本地侧」的协同清理:
import { magic } from "../../lib/magic";
import { removeTokenCookie } from "../../lib/auth-cookies";
import { getLoginSession } from "../../lib/auth";
export default async function logout(req, res) {
try {
const session = await getLoginSession(req);
if (session) {
await magic.users.logoutByIssuer(session.issuer);
removeTokenCookie(res);
}
} catch (error) {
console.error(error);
}
res.writeHead(302, { Location: "/" });
res.end();
}
它先解封本地会话拿到 issuer,调用 magic.users.logoutByIssuer(session.issuer) 通知 Magic 注销该用户发行方(避免该邮箱后续收不到/不再信任已发链接等状态问题),随后清空本地 Cookie,最后 302 重定向回首页。注意即便会话解封失败(已过期等),也会走完重定向逻辑,保证登出动作对异常会话依然收敛。
用户信息接口:为 useUser 提供数据源
pages/api/user.js 是最短的一层,却是前端所有守卫逻辑的数据源头:
import { getLoginSession } from "../../lib/auth";
export default async function user(req, res) {
const session = await getLoginSession(req);
// After getting the session you may want to fetch for the user instead
// of sending the session's payload directly, this example doesn't have a DB
// so it won't matter in this case
res.status(200).json({ user: session || null });
}
源码注释点出了一个关键的工程化升级路径:真实项目里拿到 session(内含 issuer/email)后,应再去自己的数据库查出完整用户记录返回,而不是直接回传会话载荷。由于示例刻意不接数据库(README 明确 "A DB is not included. But you can add any DB you like!"),这里直接把 session 当作 user 返回。因此如果你想接入 PostgreSQL、MongoDB 或任何存储,只需在 /api/user 内补一次查库即可,前端与 hooks 无需改动。
前端:SWR 驱动的一次性渲染与路由守卫
useUser 钩子
lib/hooks.js 用一个自包含的 fetcher 配合 SWR 封装了「获取当前用户 + 按需重定向」:
import { useEffect } from "react";
import Router from "next/router";
import useSWR from "swr";
const fetcher = (url) =>
fetch(url)
.then((r) => r.json())
.then((data) => {
return { user: data?.user || null };
});
export function useUser({ redirectTo, redirectIfFound } = {}) {
const { data, error } = useSWR("/api/user", fetcher);
const user = data?.user;
const finished = Boolean(data);
const hasUser = Boolean(user);
useEffect(() => {
if (!redirectTo || !finished) return;
if (
// If redirectTo is set, redirect if the user was not found.
(redirectTo && !redirectIfFound && !hasUser) ||
// If redirectIfFound is also set, redirect if the user was found
(redirectIfFound && hasUser)
) {
Router.push(redirectTo);
}
}, [redirectTo, redirectIfFound, finished, hasUser]);
return error ? null : user;
}
它提供两种互补的守卫语义:
redirectTo(未登录守卫):如 Profile 页useUser({ redirectTo: "/login" })——无用户且已取回数据(finished)时跳转登录页。redirectTo+redirectIfFound: true(已登录守卫):如登录页useUser({ redirectTo: "/", redirectIfFound: true })——已登录用户再访问登录页会被直接送回首页,避免重复登录。- 返回
null(无守卫):如首页与 Header 组件useUser()——只用来感知登录态以切换导航 UI。
需要留意的是,useEffect 依赖 finished 即「SWR 是否已返回数据」,从而避免首屏渲染阶段误跳转造成的闪烁。页面级组件(Home/Profile/Login)在拿到 user 对象后用 <pre>{JSON.stringify(user, null, 2)}</pre> 直接把会话明文展示出来,方便你观察登录前后的差异。
登录页与表单组件
pages/login.js 承担「拿 DID Token → 调本地 API」的编排职责,其中值得全文精读的部分是:
const magic = new Magic(process.env.NEXT_PUBLIC_MAGIC_PUBLISHABLE_KEY);
const didToken = await magic.auth.loginWithMagicLink({
email: body.email,
});
const res = await fetch("/api/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: "Bearer " + didToken,
},
body: JSON.stringify(body),
});
if (res.status === 200) {
Router.push("/");
} else {
throw new Error(await res.text());
}
浏览器会先弹出一个 Magic 托管的安全验证层(用于防机器人/防暴力破解),用户点击邮件链接后 loginWithMagicLink resolve 出 DID Token;前端随即把它放在 Authorization: Bearer ... 请求头中 POST 到 /api/login,成功后 Router.push("/") 进入首页。表单本体被抽成 components/form.js,它只渲染邮箱输入框与「Sign Up / Login」按钮,并把服务端/客户端抛出的错误展示在 .error 段落中,职责单一、便于复用。
页头导航的状态化切换
components/header.js 演示了如何用 useUser 的返回值动态切换导航项:
- 未登录:只显示
Home与Login; - 已登录:追加
Profile与指向/api/logout的普通<a>链接(注意登出是整页 GET 跳转而非 JS 请求,因此用了原生<a href>而不是next/link的行为化跳转)。
三步验收:把登录状态跑通一遍
按 pages/index.js 给出的引导步骤可完整验收整个闭环:
- 点击 Login 并输入一个邮箱,查收魔法链接完成验证。
- 会被重定向回首页,此时首页下方会打印当前登录用户的会话 JSON;进入 Profile 可看到同样的会话详情——这就是「会话通过 Cookie 中的加密 token 被持续使用」的直观证据。
- 点击 Logout,浏览器先清空 Cookie 并让 Magic 注销该 issuer;此时再访问 Profile 会被
useUser({ redirectTo: "/login" })拦截并重定向到登录页。
观察浏览器开发者工具会发现:Cookie 名为 token,带有 HttpOnly 标记,生产环境还要求 Secure;开发者工具虽然看不到其内容,但整个站点仍能通过它识别登录态——这正是 httpOnly 会话「API 可读、JS 不可读」的设计写照。
安全设计总结与扩展思路
从 lib/auth.js 与 lib/auth-cookies.js 的源码可以归纳出该示例的四层纵深安全模型:
- 凭证层:用户不接触密码,Magic 负责邮件链接 + DID Token 的一次性签发与验证;
- 服务端校验层:DID Token 只被
MAGIC_SECRET_KEY保护的 Admin SDK 验证,客户端永远拿不到也验不了; - 传输与存储层:会话经
TOKEN_SECRET+ Iron 密封后存入httpOnlyCookie,XSS 无法读取、篡改会被 unseal 阶段识破; - 生命周期层:8 小时有效期被密封在会话内部由服务端复核,登出时同步注销 Magic 侧 issuer 并清除本地 Cookie。
若要基于此示例改造为生产系统,源码注释已经指明了方向:在 /api/user 中用 session.issuer 关联自有用户表并返回完整资料;生产环境务必为 TOKEN_SECRET 使用独立的随机强密钥并纳入密钥管理服务,同时保证应用只通过 HTTPS 对外提供服务,使 secure 标志真正生效。
部署到 Vercel
两种部署路径都要求你在目标平台配置与 .env.local 一致的环境变量:
- 导入本地项目:把项目推送到 GitHub/GitLab/Bitbucket 等 Git 托管平台后导入 Vercel;导入时务必点击 Environment Variables 面板,把
.env.local中的三个变量逐项填入(注意区分NEXT_PUBLIC_前缀变量与其他变量的可见性)。 - 直接使用模板:在 Vercel 新建项目时选择该示例模板,创建向导会直接提示填写
NEXT_PUBLIC_MAGIC_PUBLISHABLE_KEY、MAGIC_SECRET_KEY、TOKEN_SECRET三个必填环境变量(README 中模板链接的env=参数已经预先声明了这三个变量,并将文档锚点指向其 Configuration 章节)。
部署完成后,由于生产环境 NODE_ENV === "production",Cookie 的 secure 标志会自动启用,认证链路即与本地开发行为对齐。若想进一步探索同类示例,可横向参考仓库中的 with-passport、with-iron-session-cache-components 等认证主题示例,对照不同会话方案在 Next.js 中的落地差异。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0627
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00