完整的认证技术全景:Session 与 JWT 怎么选、OAuth 2.0/OIDC 流程、刷新令牌轮换、Passkey(WebAuthn),以及我在 Next.js 中实际使用的认证模式。
认证是 Web 开发中唯一一个"能用"永远不够好的领域。日期选择器的 bug 让人烦躁,认证系统的 bug 就是数据泄露。
我从零实现过认证系统,在不同提供商之间迁移过,调试过令牌被盗事件,也承受过"安全以后再修"这种决策的后果。这篇文章是我希望自己入门时就能读到的全面指南。不仅仅是理论——还有实际的权衡、真实的漏洞,以及在生产压力下经受住考验的模式。
我们将覆盖完整的技术全景:Session、JWT、OAuth 2.0、Passkey、MFA 和授权。读完后,你不仅会理解每种机制是怎么工作的,还会理解什么时候用它以及为什么替代方案存在。
这是你面对的第一个决策,互联网上充满了关于它的糟糕建议。让我讲讲真正重要的东西。
Session 是最原始的方式。服务器创建一条 session 记录,存到某个地方(数据库、Redis、内存),然后通过 cookie 给客户端一个不透明的 session ID。
// 简化的 session 创建
import { randomBytes } from "crypto";
import { cookies } from "next/headers";
interface Session {
userId: string;
createdAt: Date;
expiresAt: Date;
ipAddress: string;
userAgent: string;
}
async function createSession(userId: string, request: Request): Promise<string> {
const sessionId = randomBytes(32).toString("hex");
const session: Session = {
userId,
createdAt: new Date(),
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 小时
ipAddress: request.headers.get("x-forwarded-for") ?? "unknown",
userAgent: request.headers.get("user-agent") ?? "unknown",
};
// 存入你的数据库或 Redis
await redis.set(`session:${sessionId}`, JSON.stringify(session), "EX", 86400);
const cookieStore = await cookies();
cookieStore.set("session_id", sessionId, {
httpOnly: true,
secure: true,
sameSite: "lax",
maxAge: 86400,
path: "/",
});
return sessionId;
}优势是实打实的:
劣势也是实打实的:
JWT 翻转了这个模型。不是在服务器上存储 session 状态,而是把它编码到一个签名令牌中,由客户端持有。
import { SignJWT, jwtVerify } from "jose";
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
async function createAccessToken(userId: string, role: string): Promise<string> {
return new SignJWT({ sub: userId, role })
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime("15m")
.setIssuer("https://example.com")
.setAudience("https://example.com")
.sign(secret);
}
async function verifyAccessToken(token: string) {
try {
const { payload } = await jwtVerify(token, secret, {
issuer: "https://example.com",
audience: "https://example.com",
});
return payload;
} catch {
return null;
}
}优势:
劣势——这些是人们一带而过的:
我的经验法则:
用 Session 当: 你有一个单体应用,你需要即时撤销,你在构建账户安全至关重要的面向消费者的产品,或者你的认证需求可能经常变化。
用 JWT 当: 你有微服务架构,服务需要独立验证身份,你在构建 API 到 API 的通信,或者你在实现第三方认证系统。
实践中: 大多数应用应该用 Session。"JWT 更有扩展性"这个论点只在你确实有 session 存储解决不了的扩展问题时才适用——而 Redis 每秒能处理数百万次 session 查找。我见过太多项目因为 JWT 听起来更现代就选了它,然后建了一个黑名单和刷新令牌系统,比直接用 Session 还复杂。
即使你选择了基于 Session 的认证,你也会通过 OAuth、OIDC 和第三方集成遇到 JWT。理解其内部原理是不可商量的。
JWT 有三部分,用点号分隔:header.payload.signature
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiJ1c2VyXzEyMyIsInJvbGUiOiJhZG1pbiIsImlhdCI6MTcwOTMxMjAwMCwiZXhwIjoxNzA5MzEyOTAwfQ.
kQ8s7nR2xC...
Header — 声明算法和令牌类型:
{
"alg": "RS256",
"typ": "JWT"
}Payload — 包含声明。标准声明使用短名称:
{
"sub": "user_123", // Subject(关于谁)
"iss": "https://auth.example.com", // Issuer(谁创建的)
"aud": "https://api.example.com", // Audience(谁应该接受)
"iat": 1709312000, // Issued At(Unix 时间戳)
"exp": 1709312900, // Expiration(Unix 时间戳)
"role": "admin" // 自定义声明
}Signature — 证明令牌没有被篡改。通过用密钥对编码后的 header 和 payload 签名来创建。
HS256(HMAC-SHA256)— 对称的。同一个密钥用于签名和验证。简单,但每个需要验证令牌的服务都必须有这个密钥。如果其中任何一个被攻破,攻击者就能伪造令牌。
RS256(RSA-SHA256)— 非对称的。私钥签名,公钥验证。只有认证服务器需要私钥。任何服务都能用公钥验证。如果一个验证服务被攻破,攻击者只能读取令牌但不能伪造。
import { SignJWT, jwtVerify, importPKCS8, importSPKI } from "jose";
// RS256 — 当多个服务需要验证令牌时使用
const privateKeyPem = process.env.JWT_PRIVATE_KEY!;
const publicKeyPem = process.env.JWT_PUBLIC_KEY!;
async function signWithRS256(payload: Record<string, unknown>) {
const privateKey = await importPKCS8(privateKeyPem, "RS256");
return new SignJWT(payload)
.setProtectedHeader({ alg: "RS256", typ: "JWT" })
.setIssuedAt()
.setExpirationTime("15m")
.sign(privateKey);
}
async function verifyWithRS256(token: string) {
const publicKey = await importSPKI(publicKeyPem, "RS256");
const { payload } = await jwtVerify(token, publicKey, {
algorithms: ["RS256"], // 关键:始终限制算法
});
return payload;
}规则:当令牌跨服务边界时使用 RS256。 只有当同一个服务既签名又验证时才用 HS256。
alg: none 攻击#这是最著名的 JWT 漏洞,而且简单得令人尴尬。一些 JWT 库曾经会:
alg 字段alg: "none",完全跳过签名验证攻击者可以拿一个有效的 JWT,修改载荷(比如设置 "role": "admin"),把 alg 设为 "none",移除签名,然后发送。服务器会接受它。
// 有漏洞 — 永远不要这样做
function verifyJwt(token: string) {
const [headerB64, payloadB64, signature] = token.split(".");
const header = JSON.parse(atob(headerB64));
if (header.alg === "none") {
// "不需要签名" — 灾难性的
return JSON.parse(atob(payloadB64));
}
// ... 验证签名
}修复很简单:始终显式指定期望的算法。 永远不要让令牌告诉你如何验证它。
// 安全 — 算法是硬编码的,不是从令牌读取的
const { payload } = await jwtVerify(token, key, {
algorithms: ["RS256"], // 只接受 RS256 — 忽略 header
});现代库如 jose 默认正确处理这个问题,但你仍然应该显式传入 algorithms 选项作为深度防御。
和上面相关:如果服务器配置为接受 RS256,攻击者可能会:
alg: "HS256" 的令牌如果服务器读取 alg header 并切换到 HS256 验证,公钥(所有人都知道的)就变成了共享密钥。签名是有效的。攻击者伪造了一个令牌。
同样,修复方法一样:永远不要信任令牌 header 中的算法。始终硬编码。
如果你使用 JWT,你需要一个刷新令牌策略。发送长期有效的访问令牌是在自找麻烦——如果它被盗,攻击者在整个有效期内都有访问权限。
模式如下:
import { randomBytes } from "crypto";
interface RefreshTokenRecord {
tokenHash: string;
userId: string;
familyId: string; // 将相关令牌分组
used: boolean;
expiresAt: Date;
createdAt: Date;
}
async function issueTokenPair(userId: string) {
const familyId = randomBytes(16).toString("hex");
const accessToken = await createAccessToken(userId);
const refreshToken = randomBytes(64).toString("hex");
const refreshTokenHash = await hashToken(refreshToken);
// 存储刷新令牌记录
await db.refreshToken.create({
data: {
tokenHash: refreshTokenHash,
userId,
familyId,
used: false,
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
createdAt: new Date(),
},
});
return { accessToken, refreshToken };
}每次客户端使用刷新令牌获取新的访问令牌时,你签发一个新的刷新令牌并使旧的失效:
async function rotateTokens(incomingRefreshToken: string) {
const tokenHash = await hashToken(incomingRefreshToken);
const record = await db.refreshToken.findUnique({
where: { tokenHash },
});
if (!record) {
// 令牌不存在 — 可能被盗
return null;
}
if (record.expiresAt < new Date()) {
// 令牌过期
await db.refreshToken.delete({ where: { tokenHash } });
return null;
}
if (record.used) {
// 这个令牌已经被使用过了。
// 有人在重放它 — 要么是合法用户
// 要么是攻击者。无论如何,销毁整个家族。
await db.refreshToken.deleteMany({
where: { familyId: record.familyId },
});
console.error(
`Refresh token reuse detected for user ${record.userId}, family ${record.familyId}. All tokens in family invalidated.`,
);
return null;
}
// 标记当前令牌为已使用(不删除 — 我们需要它来检测重用)
await db.refreshToken.update({
where: { tokenHash },
data: { used: true },
});
// 用相同的家族 ID 签发新的令牌对
const newRefreshToken = randomBytes(64).toString("hex");
const newRefreshTokenHash = await hashToken(newRefreshToken);
await db.refreshToken.create({
data: {
tokenHash: newRefreshTokenHash,
userId: record.userId,
familyId: record.familyId, // 同一家族
used: false,
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
createdAt: new Date(),
},
});
const newAccessToken = await createAccessToken(record.userId);
return { accessToken: newAccessToken, refreshToken: newRefreshToken };
}考虑这个场景:
没有重用检测的话,用户只是得到一个错误。攻击者继续用令牌 B。用户重新登录,永远不知道账户被攻破了。
有了重用检测和家族失效:当用户试图使用已经用过的令牌 A 时,系统检测到重用,使家族中的所有令牌(包括 B)失效,强制用户和攻击者都重新认证。用户得到一个"请重新登录"的提示,可能会意识到有什么不对。
这是 Auth0、Okta 和 Auth.js 使用的方法。它不完美——如果攻击者在合法用户之前使用了令牌,合法用户就变成了触发重用警报的那个人。但这是我们用持有者令牌能做到的最好的了。
OAuth 2.0 和 OpenID Connect 是"用 Google/GitHub/Apple 登录"背后的协议。即使你使用库,理解它们也是必不可少的,因为当事情出问题时——而且一定会——你需要知道协议层面正在发生什么。
OAuth 2.0 是一个授权协议。它回答的是:"这个应用能访问这个用户的数据吗?"结果是一个授权特定权限(scope)的访问令牌。
OpenID Connect (OIDC) 是建立在 OAuth 2.0 之上的认证层。它回答的是:"这个用户是谁?"结果是一个包含用户身份信息的 ID 令牌(一个 JWT)。
当你"用 Google 登录"时,你在使用 OIDC。Google 告诉你的应用用户是谁(认证)。你也可以请求 OAuth scope 来访问他们的日历或云盘(授权)。
这是你应该用于 Web 应用的流程。PKCE(Proof Key for Code Exchange)最初是为移动应用设计的,但现在推荐所有客户端使用,包括服务端应用。
import { randomBytes, createHash } from "crypto";
// 第一步:生成 PKCE 值并重定向用户
function initiateOAuthFlow() {
// 代码验证器:随机 43-128 字符的字符串
const codeVerifier = randomBytes(32).toString("base64url").slice(0, 43);
// 代码挑战:验证器的 SHA256 哈希,base64url 编码
const codeChallenge = createHash("sha256").update(codeVerifier).digest("base64url");
// State:用于 CSRF 防护的随机值
const state = randomBytes(16).toString("hex");
// 在重定向前把两者都存到 session 中(服务端!)
// 永远不要把 code_verifier 放在 cookie 或 URL 参数中
session.codeVerifier = codeVerifier;
session.oauthState = state;
const authUrl = new URL("https://accounts.google.com/o/oauth2/v2/auth");
authUrl.searchParams.set("client_id", process.env.GOOGLE_CLIENT_ID!);
authUrl.searchParams.set("redirect_uri", "https://example.com/api/auth/callback/google");
authUrl.searchParams.set("response_type", "code");
authUrl.searchParams.set("scope", "openid email profile");
authUrl.searchParams.set("state", state);
authUrl.searchParams.set("code_challenge", codeChallenge);
authUrl.searchParams.set("code_challenge_method", "S256");
return authUrl.toString();
}// 第二步:处理回调
async function handleOAuthCallback(request: Request) {
const url = new URL(request.url);
const code = url.searchParams.get("code");
const state = url.searchParams.get("state");
const error = url.searchParams.get("error");
// 检查提供商返回的错误
if (error) {
throw new Error(`OAuth error: ${error}`);
}
// 验证 state 匹配(CSRF 防护)
if (state !== session.oauthState) {
throw new Error("State mismatch — possible CSRF attack");
}
// 用授权码交换令牌
const tokenResponse = await fetch("https://oauth2.googleapis.com/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
code: code!,
redirect_uri: "https://example.com/api/auth/callback/google",
client_id: process.env.GOOGLE_CLIENT_ID!,
client_secret: process.env.GOOGLE_CLIENT_SECRET!,
code_verifier: session.codeVerifier, // PKCE:证明是我们发起的流程
}),
});
const tokens = await tokenResponse.json();
// tokens.access_token — 用于调用 Google API
// tokens.id_token — 包含用户身份的 JWT(OIDC)
// tokens.refresh_token — 用于获取新的访问令牌
// 第三步:验证 ID 令牌并提取用户信息
const idTokenPayload = await verifyGoogleIdToken(tokens.id_token);
return {
googleId: idTokenPayload.sub,
email: idTokenPayload.email,
name: idTokenPayload.name,
picture: idTokenPayload.picture,
};
}每个 OAuth/OIDC 提供商都暴露这些:
state 参数防止对 OAuth 回调的 CSRF 攻击。没有它:
https://yourapp.com/callback?code=ATTACKER_CODE有了 state:你的应用生成一个随机值,存到 session 中,并包含在授权 URL 中。当回调来时,你验证 state 是否匹配。攻击者无法伪造这个值,因为他们无法访问受害者的 session。
Auth.js 是我在大多数 Next.js 项目中首选的工具。它处理 OAuth 流程、session 管理、数据库持久化和 CSRF 保护。以下是一个生产就绪的配置。
// src/lib/auth.ts
import NextAuth from "next-auth";
import Google from "next-auth/providers/google";
import GitHub from "next-auth/providers/github";
import Credentials from "next-auth/providers/credentials";
import { PrismaAdapter } from "@auth/prisma-adapter";
import { prisma } from "@/lib/prisma";
import { verifyPassword } from "@/lib/password";
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: PrismaAdapter(prisma),
// 使用数据库 session(不是 JWT)以获得更好的安全性
session: {
strategy: "database",
maxAge: 30 * 24 * 60 * 60, // 30 天
updateAge: 24 * 60 * 60, // 每 24 小时延长 session
},
providers: [
Google({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
// 请求特定的 scope
authorization: {
params: {
scope: "openid email profile",
prompt: "consent",
access_type: "offline", // 获取刷新令牌
},
},
}),
GitHub({
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
}),
// 邮箱/密码登录(谨慎使用)
Credentials({
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" },
},
authorize: async (credentials) => {
if (!credentials?.email || !credentials?.password) {
return null;
}
const user = await prisma.user.findUnique({
where: { email: credentials.email as string },
});
if (!user || !user.passwordHash) {
return null;
}
const isValid = await verifyPassword(credentials.password as string, user.passwordHash);
if (!isValid) {
return null;
}
return {
id: user.id,
email: user.email,
name: user.name,
image: user.image,
};
},
}),
],
callbacks: {
// 控制谁可以登录
async signIn({ user, account }) {
// 阻止被封禁的用户登录
if (user.id) {
const dbUser = await prisma.user.findUnique({
where: { id: user.id },
select: { banned: true },
});
if (dbUser?.banned) return false;
}
return true;
},
// 给 session 添加自定义字段
async session({ session, user }) {
if (session.user) {
session.user.id = user.id;
// 从数据库获取角色
const dbUser = await prisma.user.findUnique({
where: { id: user.id },
select: { role: true },
});
session.user.role = dbUser?.role ?? "user";
}
return session;
},
},
pages: {
signIn: "/login",
error: "/auth/error",
verifyRequest: "/auth/verify",
},
});// src/app/api/auth/[...nextauth]/route.ts
import { handlers } from "@/lib/auth";
export const { GET, POST } = handlers;// src/middleware.ts
import { auth } from "@/lib/auth";
import { NextResponse } from "next/server";
export default auth((req) => {
const isLoggedIn = !!req.auth;
const isAuthPage = req.nextUrl.pathname.startsWith("/login") || req.nextUrl.pathname.startsWith("/register");
const isProtectedRoute =
req.nextUrl.pathname.startsWith("/dashboard") ||
req.nextUrl.pathname.startsWith("/settings") ||
req.nextUrl.pathname.startsWith("/admin");
const isAdminRoute = req.nextUrl.pathname.startsWith("/admin");
// 把已登录用户从认证页面重定向走
if (isLoggedIn && isAuthPage) {
return NextResponse.redirect(new URL("/dashboard", req.nextUrl));
}
// 把未认证用户重定向到登录页
if (!isLoggedIn && isProtectedRoute) {
const callbackUrl = encodeURIComponent(req.nextUrl.pathname);
return NextResponse.redirect(new URL(`/login?callbackUrl=${callbackUrl}`, req.nextUrl));
}
// 检查管理员角色
if (isAdminRoute && req.auth?.user?.role !== "admin") {
return NextResponse.redirect(new URL("/dashboard", req.nextUrl));
}
return NextResponse.next();
});
export const config = {
matcher: ["/dashboard/:path*", "/settings/:path*", "/admin/:path*", "/login", "/register"],
};// src/app/dashboard/page.tsx
import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";
export default async function DashboardPage() {
const session = await auth();
if (!session?.user) {
redirect("/login");
}
return (
<div>
<h1>Welcome, {session.user.name}</h1>
<p>Role: {session.user.role}</p>
</div>
);
}"use client";
import { useSession } from "next-auth/react";
export function UserMenu() {
const { data: session, status } = useSession();
if (status === "loading") {
return <div>Loading...</div>;
}
if (status === "unauthenticated") {
return <a href="/login">Sign In</a>;
}
return (
<div>
<img
src={session?.user?.image ?? "/default-avatar.png"}
alt={session?.user?.name ?? "User"}
/>
<span>{session?.user?.name}</span>
</div>
);
}Passkey 是近年来最重要的认证改进。它们抗钓鱼、抗重放,消除了整类密码相关的漏洞。如果你在 2026 年开始一个新项目,你应该支持 Passkey。
Passkey 使用公钥密码学,由生物识别或设备 PIN 支撑:
没有共享密钥通过网络传输。没什么可以钓鱼的,没什么可以泄露的,没什么可以撞库的。
当创建 Passkey 时,它被绑定到源(如 https://example.com)。浏览器只会在创建 Passkey 的确切源上使用它。如果攻击者创建了一个看起来很像的站点 https://exarnple.com,Passkey 根本不会被提供。这是由浏览器强制执行的,不依赖用户的警觉性。
这和密码根本不同,用户在钓鱼网站上输入凭据是家常便饭,因为页面看起来没问题。
SimpleWebAuthn 是我推荐的库。它正确处理 WebAuthn 协议,TypeScript 类型也很好。
// 服务端:注册
import { generateRegistrationOptions, verifyRegistrationResponse } from "@simplewebauthn/server";
import type { GenerateRegistrationOptionsOpts, VerifiedRegistrationResponse } from "@simplewebauthn/server";
const rpName = "example.com";
const rpID = "example.com";
const origin = "https://example.com";
async function startRegistration(userId: string, userEmail: string) {
// 获取用户已有的 passkey 以排除它们
const existingCredentials = await db.credential.findMany({
where: { userId },
select: { credentialId: true, transports: true },
});
const options: GenerateRegistrationOptionsOpts = {
rpName,
rpID,
userID: new TextEncoder().encode(userId),
userName: userEmail,
attestationType: "none", // 大多数应用不需要认证证明
excludeCredentials: existingCredentials.map((cred) => ({
id: cred.credentialId,
transports: cred.transports,
})),
authenticatorSelection: {
residentKey: "preferred",
userVerification: "preferred",
},
};
const registrationOptions = await generateRegistrationOptions(options);
// 临时存储挑战 — 验证时需要
await redis.set(
`webauthn:challenge:${userId}`,
registrationOptions.challenge,
"EX",
300, // 5 分钟过期
);
return registrationOptions;
}
async function finishRegistration(userId: string, response: unknown) {
const expectedChallenge = await redis.get(`webauthn:challenge:${userId}`);
if (!expectedChallenge) {
throw new Error("Challenge expired or not found");
}
let verification: VerifiedRegistrationResponse;
try {
verification = await verifyRegistrationResponse({
response: response as any,
expectedChallenge,
expectedOrigin: origin,
expectedRPID: rpID,
});
} catch (error) {
throw new Error(`Registration verification failed: ${error}`);
}
if (!verification.verified || !verification.registrationInfo) {
throw new Error("Registration verification failed");
}
const { credential } = verification.registrationInfo;
// 在数据库中存储凭证
await db.credential.create({
data: {
userId,
credentialId: credential.id,
publicKey: Buffer.from(credential.publicKey),
counter: credential.counter,
transports: credential.transports ?? [],
},
});
// 清理
await redis.del(`webauthn:challenge:${userId}`);
return { verified: true };
}// 服务端:认证
import { generateAuthenticationOptions, verifyAuthenticationResponse } from "@simplewebauthn/server";
async function startAuthentication(userId?: string) {
let allowCredentials;
// 如果我们知道用户(比如他们输入了邮箱),限制为他们的 passkey
if (userId) {
const credentials = await db.credential.findMany({
where: { userId },
select: { credentialId: true, transports: true },
});
allowCredentials = credentials.map((cred) => ({
id: cred.credentialId,
transports: cred.transports,
}));
}
const options = await generateAuthenticationOptions({
rpID,
allowCredentials,
userVerification: "preferred",
});
// 存储挑战用于验证
const challengeKey = userId ? `webauthn:auth:${userId}` : `webauthn:auth:${options.challenge}`;
await redis.set(challengeKey, options.challenge, "EX", 300);
return options;
}
async function finishAuthentication(response: any, expectedChallenge: string, userId: string) {
const credential = await db.credential.findUnique({
where: { credentialId: response.id },
});
if (!credential) {
throw new Error("Credential not found");
}
const verification = await verifyAuthenticationResponse({
response,
expectedChallenge,
expectedOrigin: origin,
expectedRPID: rpID,
credential: {
id: credential.credentialId,
publicKey: credential.publicKey,
counter: credential.counter,
transports: credential.transports,
},
});
if (!verification.verified) {
throw new Error("Authentication verification failed");
}
// 重要:更新计数器以防止重放攻击
await db.credential.update({
where: { credentialId: response.id },
data: {
counter: verification.authenticationInfo.newCounter,
},
});
return { verified: true, userId: credential.userId };
}// 客户端:注册
import { startRegistration as webAuthnRegister } from "@simplewebauthn/browser";
async function registerPasskey() {
// 从服务器获取选项
const optionsResponse = await fetch("/api/auth/webauthn/register", {
method: "POST",
});
const options = await optionsResponse.json();
try {
// 这会触发浏览器的 passkey UI(生物识别提示)
const credential = await webAuthnRegister(options);
// 发送凭证到服务器进行验证
const verifyResponse = await fetch("/api/auth/webauthn/register/verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(credential),
});
const result = await verifyResponse.json();
if (result.verified) {
console.log("Passkey registered successfully!");
}
} catch (error) {
if ((error as Error).name === "NotAllowedError") {
console.log("User cancelled the passkey registration");
}
}
}你会遇到的两个术语:
attestationType: "none" 即可。即使有了 Passkey,你也会遇到需要 TOTP MFA 的场景——Passkey 作为密码之外的第二因素,或者支持设备不支持 Passkey 的用户。
TOTP 是 Google Authenticator、Authy 和 1Password 背后的协议。它的工作方式是:
import { createHmac, randomBytes } from "crypto";
// 为用户生成 TOTP 密钥
function generateTOTPSecret(): string {
const buffer = randomBytes(20);
return base32Encode(buffer);
}
function base32Encode(buffer: Buffer): string {
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
let result = "";
let bits = 0;
let value = 0;
for (const byte of buffer) {
value = (value << 8) | byte;
bits += 8;
while (bits >= 5) {
result += alphabet[(value >>> (bits - 5)) & 0x1f];
bits -= 5;
}
}
if (bits > 0) {
result += alphabet[(value << (5 - bits)) & 0x1f];
}
return result;
}
// 生成用于二维码的 TOTP URI
function generateTOTPUri(secret: string, userEmail: string, issuer: string = "example.com"): string {
const encodedIssuer = encodeURIComponent(issuer);
const encodedEmail = encodeURIComponent(userEmail);
return `otpauth://totp/${encodedIssuer}:${encodedEmail}?secret=${secret}&issuer=${encodedIssuer}&algorithm=SHA1&digits=6&period=30`;
}// 验证 TOTP 代码
function verifyTOTP(secret: string, code: string, window: number = 1): boolean {
const secretBuffer = base32Decode(secret);
const now = Math.floor(Date.now() / 1000);
// 检查当前时间步和相邻的(时钟偏差容忍)
for (let i = -window; i <= window; i++) {
const timeStep = Math.floor(now / 30) + i;
const expectedCode = generateTOTPCode(secretBuffer, timeStep);
// 常数时间比较以防止计时攻击
if (timingSafeEqual(code, expectedCode)) {
return true;
}
}
return false;
}
function generateTOTPCode(secret: Buffer, timeStep: number): string {
// 将时间步转换为 8 字节大端序缓冲区
const timeBuffer = Buffer.alloc(8);
timeBuffer.writeBigInt64BE(BigInt(timeStep));
// HMAC-SHA1
const hmac = createHmac("sha1", secret).update(timeBuffer).digest();
// 动态截断
const offset = hmac[hmac.length - 1] & 0x0f;
const code =
((hmac[offset] & 0x7f) << 24) |
((hmac[offset + 1] & 0xff) << 16) |
((hmac[offset + 2] & 0xff) << 8) |
(hmac[offset + 3] & 0xff);
return (code % 1_000_000).toString().padStart(6, "0");
}
function timingSafeEqual(a: string, b: string): boolean {
if (a.length !== b.length) return false;
const bufA = Buffer.from(a);
const bufB = Buffer.from(b);
return createHmac("sha256", "key").update(bufA).digest().equals(createHmac("sha256", "key").update(bufB).digest());
}用户会丢手机。在 MFA 设置时始终生成备份码:
import { randomBytes, createHash } from "crypto";
function generateBackupCodes(count: number = 10): string[] {
return Array.from(
{ length: count },
() => randomBytes(4).toString("hex").toUpperCase(), // 8 字符十六进制码
);
}
async function storeBackupCodes(userId: string, codes: string[]) {
// 存储前对备份码做哈希 — 像密码一样对待
const hashedCodes = codes.map((code) => createHash("sha256").update(code).digest("hex"));
await db.backupCode.createMany({
data: hashedCodes.map((hash) => ({
userId,
codeHash: hash,
used: false,
})),
});
// 只返回一次明文码给用户保存
// 之后我们只有哈希值
return codes;
}
async function verifyBackupCode(userId: string, code: string): Promise<boolean> {
const codeHash = createHash("sha256").update(code.toUpperCase().replace(/\s/g, "")).digest("hex");
const backupCode = await db.backupCode.findFirst({
where: {
userId,
codeHash,
used: false,
},
});
if (!backupCode) return false;
// 标记为已使用 — 每个备份码只能用一次
await db.backupCode.update({
where: { id: backupCode.id },
data: { used: true, usedAt: new Date() },
});
return true;
}MFA 恢复是大多数教程跳过、大多数真实应用搞砸的部分。以下是我实现的方式:
等待期至关重要。如果攻击者已经攻破了用户的邮箱,你不希望让他们立即禁用 MFA。24 小时的延迟给了合法用户注意到邮件并进行干预的时间。
async function initiateAccountRecovery(email: string) {
const user = await db.user.findUnique({ where: { email } });
if (!user) {
// 不要泄露账户是否存在
return { message: "If that email exists, we've sent recovery instructions." };
}
const recoveryToken = randomBytes(32).toString("hex");
const tokenHash = createHash("sha256").update(recoveryToken).digest("hex");
await db.recoveryRequest.create({
data: {
userId: user.id,
tokenHash,
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 小时
status: "pending",
},
});
// 发送带恢复链接的邮件
await sendEmail(email, {
subject: "Account Recovery Request",
body: `
A request was made to disable MFA on your account.
If this was you, click the link below after 24 hours: ...
If this was NOT you, please change your password immediately.
`,
});
return { message: "If that email exists, we've sent recovery instructions." };
}认证告诉你某人是谁。授权告诉你他们被允许做什么。搞错这个就是上新闻的节奏。
RBAC(基于角色的访问控制):用户有角色,角色有权限。简单,容易推理,适用于大多数应用。
// RBAC — 直接的角色检查
type Role = "user" | "editor" | "admin" | "super_admin";
const ROLE_PERMISSIONS: Record<Role, string[]> = {
user: ["read:own_profile", "update:own_profile", "read:posts"],
editor: ["read:own_profile", "update:own_profile", "read:posts", "create:posts", "update:posts"],
admin: [
"read:own_profile",
"update:own_profile",
"read:posts",
"create:posts",
"update:posts",
"delete:posts",
"read:users",
"update:users",
],
super_admin: ["*"], // 通配符要谨慎
};
function hasPermission(role: Role, permission: string): boolean {
const permissions = ROLE_PERMISSIONS[role];
return permissions.includes("*") || permissions.includes(permission);
}
// 在 API 路由中使用
export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) {
const session = await auth();
if (!session?.user) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
if (!hasPermission(session.user.role as Role, "delete:posts")) {
return Response.json({ error: "Forbidden" }, { status: 403 });
}
const { id } = await params;
await db.post.delete({ where: { id } });
return Response.json({ success: true });
}ABAC(基于属性的访问控制):权限取决于用户、资源和上下文的属性。更灵活但也更复杂。
// ABAC — 当 RBAC 不够用时
interface PolicyContext {
user: {
id: string;
role: string;
department: string;
clearanceLevel: number;
};
resource: {
type: string;
ownerId: string;
classification: string;
department: string;
};
action: string;
environment: {
ipAddress: string;
time: Date;
mfaVerified: boolean;
};
}
function evaluatePolicy(context: PolicyContext): boolean {
const { user, resource, action, environment } = context;
// 用户始终可以读取自己的资源
if (action === "read" && resource.ownerId === user.id) {
return true;
}
// 管理员可以读取其部门内的任何资源
if (action === "read" && user.role === "admin" && user.department === resource.department) {
return true;
}
// 机密资源需要 MFA 和最低权限等级
if (resource.classification === "confidential") {
if (!environment.mfaVerified) return false;
if (user.clearanceLevel < 3) return false;
}
// 工作时间外阻止破坏性操作
if (action === "delete") {
const hour = environment.time.getHours();
if (hour < 9 || hour > 17) return false;
}
return false; // 默认拒绝
}这是最重要的授权原则:在每个信任边界检查权限,不仅仅是在 UI 层。
// 差 — 只在组件中检查
function DeleteButton({ post }: { post: Post }) {
const { data: session } = useSession();
// 这隐藏了按钮,但不能阻止删除
if (session?.user?.role !== "admin") return null;
return <button onClick={() => deletePost(post.id)}>Delete</button>;
}
// 也差 — 在 server action 中检查但不在 API 路由中检查
async function deletePostAction(postId: string) {
const session = await auth();
if (session?.user?.role !== "admin") throw new Error("Forbidden");
await db.post.delete({ where: { id: postId } });
}
// 攻击者仍然可以直接调用 POST /api/posts/123
// 好 — 在每个边界检查
// 1. 在 UI 中隐藏按钮(用户体验,不是安全)
// 2. 在 server action 中检查(纵深防御)
// 3. 在 API 路由中检查(实际的安全边界)
// 4. 可选地,在中间件中检查(路由级保护)UI 检查是为了用户体验。服务端检查是为了安全。永远不要只依赖其中一个。
中间件在每个匹配的请求之前运行。它是做粗粒度访问控制的好地方:
// "这个用户是否被允许访问这个区域?"
// 细粒度检查("这个用户能编辑这篇文章吗?")属于路由处理器
// 因为中间件不容易访问请求体或路由参数。
export default auth((req) => {
const path = req.nextUrl.pathname;
const role = req.auth?.user?.role;
// 路由级访问控制
const routeAccess: Record<string, Role[]> = {
"/admin": ["admin", "super_admin"],
"/editor": ["editor", "admin", "super_admin"],
"/dashboard": ["user", "editor", "admin", "super_admin"],
};
for (const [route, allowedRoles] of Object.entries(routeAccess)) {
if (path.startsWith(route)) {
if (!role || !allowedRoles.includes(role as Role)) {
return NextResponse.redirect(new URL("/unauthorized", req.nextUrl));
}
}
}
return NextResponse.next();
});这些是我在真实代码库中最常看到的攻击。理解它们是必要的。
攻击方式:攻击者在你的站点上创建一个有效的 session,然后诱骗受害者使用那个 session ID(比如通过 URL 参数或通过子域设置 cookie)。当受害者登录时,攻击者的 session 现在有了一个已认证的用户。
修复方法:认证成功后始终重新生成 session ID。 永远不要让认证前的 session ID 延续到认证后的 session。
async function login(credentials: { email: string; password: string }, request: Request) {
const user = await verifyCredentials(credentials);
if (!user) throw new Error("Invalid credentials");
// 关键:删除旧 session 并创建新的
const oldSessionId = getSessionIdFromCookie(request);
if (oldSessionId) {
await redis.del(`session:${oldSessionId}`);
}
// 创建一个全新的 session,带有新的 ID
const newSessionId = await createSession(user.id, request);
return newSessionId;
}攻击方式:用户登录了你的站点。他们访问一个恶意页面,该页面向你的站点发起请求。因为 cookie 是自动发送的,请求是已认证的。
现代修复方案:SameSite cookie。 设置 SameSite: Lax(现在大多数浏览器的默认值)可以阻止跨源 POST 请求发送 cookie,覆盖了大多数 CSRF 场景。
// SameSite=Lax 覆盖了大多数 CSRF 场景:
// - 阻止跨源 POST、PUT、DELETE 发送 cookie
// - 允许跨源 GET(顶级导航)发送 cookie
// 这没问题因为 GET 请求不应该有副作用
cookieStore.set("session_id", sessionId, {
httpOnly: true,
secure: true,
sameSite: "lax", // 这就是你的 CSRF 防护
maxAge: 86400,
path: "/",
});对于接受 JSON 的 API,你免费获得额外保护:Content-Type: application/json header 无法被 HTML 表单设置,CORS 阻止其他源上的 JavaScript 发出带自定义 header 的请求。
如果你需要更强的保证(比如你接受表单提交),使用双重提交 cookie 模式或同步器令牌。Auth.js 会为你处理这些。
攻击方式:攻击者构造一个 OAuth 回调 URL,在认证后重定向到他们的站点:https://yourapp.com/callback?redirect_to=https://evil.com/steal-token
如果你的回调处理器盲目重定向到 redirect_to 参数,用户就会到达攻击者的站点,URL 中可能还带有令牌。
// 有漏洞
async function handleCallback(request: Request) {
const url = new URL(request.url);
const redirectTo = url.searchParams.get("redirect_to") ?? "/";
// ... 认证用户 ...
return Response.redirect(redirectTo); // 可能是 https://evil.com!
}
// 安全
async function handleCallback(request: Request) {
const url = new URL(request.url);
const redirectTo = url.searchParams.get("redirect_to") ?? "/";
// 验证重定向 URL
const safeRedirect = sanitizeRedirectUrl(redirectTo, request.url);
// ... 认证用户 ...
return Response.redirect(safeRedirect);
}
function sanitizeRedirectUrl(redirect: string, baseUrl: string): string {
try {
const url = new URL(redirect, baseUrl);
const base = new URL(baseUrl);
// 只允许重定向到同源
if (url.origin !== base.origin) {
return "/";
}
// 只允许路径重定向(不允许 javascript: 或 data: URI)
if (!url.pathname.startsWith("/")) {
return "/";
}
return url.pathname + url.search;
} catch {
return "/";
}
}如果你把令牌放在 URL 中(别这样做),它们会通过 Referer header 在用户点击链接时泄露。这造成过真实的泄露,包括 GitHub。
规则:
Referrer-Policy: strict-origin-when-cross-origin(或更严格的)// 在你的 Next.js 中间件或布局中
const headers = new Headers();
headers.set("Referrer-Policy", "strict-origin-when-cross-origin");一个不太知名的攻击:一些 JWT 库支持 jwk 或 jku header,告诉验证者去哪里找公钥。攻击者可以:
jwk header 设为指向他们的公钥如果你的库盲目获取并使用 jwk header 中的密钥,签名就会验证通过。修复方法:永远不允许令牌指定自己的验证密钥。始终使用你自己配置中的密钥。
在多年构建认证系统后,以下是我今天实际使用的。
这是我新项目的默认技术栈:
Session 策略是数据库支撑的(不是 JWT),这给了我即时撤销和简单的 session 管理。
// 这是我新项目的典型 auth.ts
import NextAuth from "next-auth";
import Google from "next-auth/providers/google";
import GitHub from "next-auth/providers/github";
import Passkey from "next-auth/providers/passkey";
import { PrismaAdapter } from "@auth/prisma-adapter";
import { prisma } from "@/lib/prisma";
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: PrismaAdapter(prisma),
session: { strategy: "database" },
providers: [
Google,
GitHub,
Passkey({
// Auth.js v5 内置了 passkey 支持
// 底层使用 SimpleWebAuthn
}),
],
experimental: {
enableWebAuthn: true,
},
});我在以下场景下选择托管认证提供商:
托管提供商的权衡:
jose 处理 JWT,@simplewebauthn/server 处理 Passkey,bcrypt 或 argon2 处理密码。永远不手写。crypto.randomBytes(32)。UUID v4 可以接受但熵比原始随机字节少。既然提到了——以下是 2026 年正确哈希密码的方式:
import { hash, verify } from "@node-rs/argon2";
// Argon2id 是推荐的算法
// 这些是 Web 应用的合理默认值
async function hashPassword(password: string): Promise<string> {
return hash(password, {
memoryCost: 65536, // 64 MB
timeCost: 3, // 3 次迭代
parallelism: 4, // 4 线程
});
}
async function verifyPassword(password: string, hashedPassword: string): Promise<boolean> {
try {
return await verify(hashedPassword, password);
} catch {
return false;
}
}为什么选 argon2id 而不是 bcrypt?Argon2id 是内存困难的,这意味着攻击它不仅需要 CPU 算力还需要大量 RAM。这使得 GPU 和 ASIC 攻击的成本显著增加。Bcrypt 仍然没问题——它没有被破解——但 argon2id 是新项目的更好选择。
在上线任何认证系统之前,验证以下内容:
HttpOnly、Secure、SameSite=Lax 或 Strictalg header)Referrer-Policy header这个清单不是详尽的,但它覆盖了我在生产系统中最常见到的漏洞。
认证是那种技术全景不断演进、但基本原理保持不变的领域:验证身份,签发最小必要的凭证,在每个边界检查权限,假设已被攻破。
2026 年最大的转变是 Passkey 走向主流。浏览器支持是全面的,平台支持(iCloud Keychain、Google 密码管理器)使用户体验无缝,安全属性真正优于我们之前拥有的一切。如果你在构建新应用,让 Passkey 成为你的主要登录方式,把密码当作备用方案。
第二大转变是自己造认证轮子变得越来越难以证明合理性。Auth.js v5、Clerk 和类似的解决方案正确处理了难的部分。唯一自己做的理由是你的需求确实不适合任何现有解决方案——而这比大多数开发者想的要少见。
无论你选什么,像攻击者一样测试你的认证。尝试重放令牌、伪造签名、访问不该访问的路由、操纵重定向 URL。你在上线前发现的 bug 就是不会上新闻的那些。