首页
/ Next.js 使用 Magic 实现无密码邮箱魔法链接认证的完整实战指南

Next.js 使用 Magic 实现无密码邮箱魔法链接认证的完整实战指南

2026-09-07 16:33:25作者:裴锟轩Denise

引言导读

本文围绕 Next.js 官方示例 examples/with-magic 展开,讲解如何将 Magic(基于 DID Token 的无密码邮箱魔法链接认证服务)接入 Next.js 应用,构建一套「服务端签发加密会话 Cookie、前端通过 SWR 消费用户信息、API Route 完成登录/登出」的完整闭环。读完本文你将掌握:基于邮箱魔法链接的无密码登录端到端流程、用 @hapi/iron 对会话做服务端加密与过期校验的技巧、httpOnly Cookie 的安全属性配置,以及 useUser 钩子驱动的路由守卫模式——这套骨架同样适用于需要接入任意第三方认证或自建会话体系的项目。

核心流程:无密码认证是如何工作的

该示例不包含任何密码输入框,用户只需填写邮箱即可完成登录,其核心依赖两条链路:

  1. Magic 托管链路:用户在浏览器端通过 magic-sdk 初始化客户端 SDK,调用 magic.auth.loginWithMagicLink({ email })。Magic 服务会向该邮箱发送一封「魔法链接」,用户点击后,客户端 SDK 拿到一个一次性的 DID Token(Decentralized ID Token)。
  2. 应用自有链路:前端把 DID Token 通过 Authorization: Bearer <didToken> 头发送给 Next.js API Route;服务端用 @magic-sdk/adminMAGIC_SECRET_KEY 校验并换取用户元数据(issuer、email 等),随后创建本地会话,将经过 @hapi/iron 密封加密的 Cookie 写回浏览器。

后续每个受保护请求(如 /api/user)只依赖那张 httpOnly Cookie,服务端解开密封并校验过期时间后即可识别用户,全程不再与 Magic 交互。这样一个精心设计的好处是:密码永远不落地,登录凭证是一次性、由服务端独立验证的

整个示例的目录结构非常清晰,可以按「会话基础层 → 服务端 API → 前端页面」三层阅读:

快速开始:三种方式拉起示例应用

与其他 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.jsonnext 使用 "latest",而其余关键依赖均锁定版本:@hapi/iron@6.0.0@magic-sdk/admin@1.0.0magic-sdk@1.0.1cookie@0.5.0swr@^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_ABCsk_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.jsnew 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_KEYTOKEN_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;
}

关键实现要点:

  1. setLoginSession 在写入前,把签发时刻 createdAt 与最大存活时长 maxAge 一并并入会话对象,再用 Iron.seal(obj, TOKEN_SECRET, Iron.defaults) 加密成一段不透明字符串。因此即便攻击者拿到 Cookie 原文,也无法在不知道 TOKEN_SECRET 的情况下篡改 emailissuer 等字段。
  2. getLoginSession 反向用 Iron.unseal 解密,并在服务端重新计算过期时间expiresAt = createdAt + maxAge * 1000,一旦超过即抛 Session expired。这意味着会话的生死由「密封内容中的时间戳」决定,而非依赖客户端可能伪造的 Cookie 属性,过期判断是可信的服务端逻辑。
  3. 该文件把 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,校验通过后返回用户元数据(通常包含 issueremailphoneNumber 等),这段元数据被整体作为会话主体 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 的返回值动态切换导航项:

  • 未登录:只显示 HomeLogin
  • 已登录:追加 Profile 与指向 /api/logout 的普通 <a> 链接(注意登出是整页 GET 跳转而非 JS 请求,因此用了原生 <a href> 而不是 next/link 的行为化跳转)。

三步验收:把登录状态跑通一遍

pages/index.js 给出的引导步骤可完整验收整个闭环:

  1. 点击 Login 并输入一个邮箱,查收魔法链接完成验证。
  2. 会被重定向回首页,此时首页下方会打印当前登录用户的会话 JSON;进入 Profile 可看到同样的会话详情——这就是「会话通过 Cookie 中的加密 token 被持续使用」的直观证据。
  3. 点击 Logout,浏览器先清空 Cookie 并让 Magic 注销该 issuer;此时再访问 Profile 会被 useUser({ redirectTo: "/login" }) 拦截并重定向到登录页。

观察浏览器开发者工具会发现:Cookie 名为 token,带有 HttpOnly 标记,生产环境还要求 Secure;开发者工具虽然看不到其内容,但整个站点仍能通过它识别登录态——这正是 httpOnly 会话「API 可读、JS 不可读」的设计写照。

安全设计总结与扩展思路

lib/auth.jslib/auth-cookies.js 的源码可以归纳出该示例的四层纵深安全模型:

  1. 凭证层:用户不接触密码,Magic 负责邮件链接 + DID Token 的一次性签发与验证;
  2. 服务端校验层:DID Token 只被 MAGIC_SECRET_KEY 保护的 Admin SDK 验证,客户端永远拿不到也验不了;
  3. 传输与存储层:会话经 TOKEN_SECRET + Iron 密封后存入 httpOnly Cookie,XSS 无法读取、篡改会被 unseal 阶段识破;
  4. 生命周期层: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_KEYMAGIC_SECRET_KEYTOKEN_SECRET 三个必填环境变量(README 中模板链接的 env= 参数已经预先声明了这三个变量,并将文档锚点指向其 Configuration 章节)。

部署完成后,由于生产环境 NODE_ENV === "production",Cookie 的 secure 标志会自动启用,认证链路即与本地开发行为对齐。若想进一步探索同类示例,可横向参考仓库中的 with-passportwith-iron-session-cache-components 等认证主题示例,对照不同会话方案在 Next.js 中的落地差异。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.13 K
2.75 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
857
1.35 K
docsdocs
暂无描述
Markdown
897
5.8 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
529
593
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
915
1.83 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.58 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.35 K
1.46 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.01 K
515
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
547
388