2026 年的现代认证:JWT、Session、OAuth 与 Passkey
完整的认证技术全景:Session 与 JWT 怎么选、OAuth 2.0/OIDC 流程、刷新令牌轮换、Passkey(WebAuthn),以及我在 Next.js 中实际使用的认证模式。
认证是 Web 开发中唯一一个"能用"永远不够好的领域。日期选择器的 bug 让人烦躁,认证系统的 bug 就是数据泄露。
我从零实现过认证系统,在不同提供商之间迁移过,调试过令牌被盗事件,也承受过"安全以后再修"这种决策的后果。这篇文章是我希望自己入门时就能读到的全面指南。不仅仅是理论——还有实际的权衡、真实的漏洞,以及在生产压力下经受住考验的模式。
我们将覆盖完整的技术全景:Session、JWT、OAuth 2.0、Passkey、MFA 和授权。读完后,你不仅会理解每种机制是_怎么_工作的,还会理解_什么时候_用它以及_为什么_替代方案存在。
Session vs JWT:真正的权衡#
这是你面对的第一个决策,互联网上充满了关于它的糟糕建议。让我讲讲真正重要的东西。
基于 Session 的认证#
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;
}优势是实打实的:
- 即时撤销。 删除 session 记录,用户就登出了。不用等过期。当你检测到可疑活动时这很重要。
- Session 可见性。 你可以向用户展示他们的活跃 session("在 Chrome、Windows 11、伊斯坦布尔上登录")并让他们撤销特定的 session。
- Cookie 体积小。 Session ID 通常只有 64 个字符。Cookie 永远不会变大。
- 服务端控制。 你可以更新 session 数据(把用户提升为管理员、修改权限),下次请求就生效。
劣势也是实打实的:
- 每个请求都要查数据库。 每个认证请求都需要一次 session 查找。用 Redis 是亚毫秒级的,但仍然是一个依赖。
- 水平扩展需要共享存储。 如果你有多台服务器,它们都需要访问同一个 session 存储。粘性 session 是一种脆弱的变通方案。
- CSRF 是个问题。 因为 cookie 会自动发送,你需要 CSRF 保护。SameSite cookie 在很大程度上解决了这个问题,但你需要理解为什么。
基于 JWT 的认证#
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://akousa.net")
.setAudience("https://akousa.net")
.sign(secret);
}
async function verifyAccessToken(token: string) {
try {
const { payload } = await jwtVerify(token, secret, {
issuer: "https://akousa.net",
audience: "https://akousa.net",
});
return payload;
} catch {
return null;
}
}优势:
- 不需要服务端存储。 令牌是自包含的。你验证签名并读取声明。不需要查数据库。
- 跨服务可用。 在微服务架构中,任何持有公钥的服务都能验证令牌。不需要共享 session 存储。
- 无状态扩展。 添加更多服务器无需担心 session 亲和性。
劣势——这些是人们一带而过的:
- 你无法撤销 JWT。 一旦签发,在它过期之前都是有效的。如果用户账户被攻破,你无法强制登出。你可以建黑名单,但那就重新引入了服务端状态,丧失了主要优势。
- 令牌大小。 带几个声明的 JWT 通常就有 800+ 字节。加上角色、权限和元数据,每次请求就在传输千字节量级的数据。
- 载荷可读。 载荷是 Base64 编码的,不是加密的。任何人都能解码。永远不要把敏感数据放在 JWT 里。
- 时钟偏差问题。 如果你的服务器时钟不同步(这种事会发生),过期检查就变得不可靠。
什么时候用哪个#
我的经验法则:
用 Session 当: 你有一个单体应用,你需要即时撤销,你在构建账户安全至关重要的面向消费者的产品,或者你的认证需求可能经常变化。
用 JWT 当: 你有微服务架构,服务需要独立验证身份,你在构建 API 到 API 的通信,或者你在实现第三方认证系统。
实践中: 大多数应用应该用 Session。"JWT 更有扩展性"这个论点只在你确实有 session 存储解决不了的扩展问题时才适用——而 Redis 每秒能处理数百万次 session 查找。我见过太多项目因为 JWT 听起来更现代就选了它,然后建了一个黑名单和刷新令牌系统,比直接用 Session 还复杂。
JWT 深入解析#
即使你选择了基于 Session 的认证,你也会通过 OAuth、OIDC 和第三方集成遇到 JWT。理解其内部原理是不可商量的。
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 签名来创建。
RS256 vs HS256:这真的很重要#
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 库曾经会:
- 从 header 读取
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"的令牌 - 用公钥作为 HMAC 密钥来签名
如果服务器读取 alg header 并切换到 HS256 验证,公钥(所有人都知道的)就变成了共享密钥。签名是有效的。攻击者伪造了一个令牌。
同样,修复方法一样:永远不要信任令牌 header 中的算法。始终硬编码。
刷新令牌轮换#
如果你使用 JWT,你需要一个刷新令牌策略。发送长期有效的访问令牌是在自找麻烦——如果它被盗,攻击者在整个有效期内都有访问权限。
模式如下:
- 访问令牌:短期有效(15 分钟)。用于 API 请求。
- 刷新令牌:长期有效(30 天)。仅用于获取新的访问令牌。
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 };
}为什么家族失效很重要#
考虑这个场景:
- 用户登录,获得刷新令牌 A
- 攻击者窃取了刷新令牌 A
- 攻击者用 A 获取新的令牌对(访问令牌 + 刷新令牌 B)
- 用户试图用 A(他们仍然持有的)来刷新
没有重用检测的话,用户只是得到一个错误。攻击者继续用令牌 B。用户重新登录,永远不知道账户被攻破了。
有了重用检测和家族失效:当用户试图使用已经用过的令牌 A 时,系统检测到重用,使家族中的所有令牌(包括 B)失效,强制用户和攻击者都重新认证。用户得到一个"请重新登录"的提示,可能会意识到有什么不对。
这是 Auth0、Okta 和 Auth.js 使用的方法。它不完美——如果攻击者在合法用户之前使用了令牌,合法用户就变成了触发重用警报的那个人。但这是我们用持有者令牌能做到的最好的了。
OAuth 2.0 和 OIDC#
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 来访问他们的日历或云盘(授权)。
授权码流程 + PKCE#
这是你应该用于 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 提供商都暴露这些:
- 授权端点 — 你重定向用户去登录和授权的地方。返回授权码。
- 令牌端点 — 你的服务器用授权码交换访问/刷新/ID 令牌的地方。这是服务器到服务器的调用。
- 用户信息端点 — 你可以用访问令牌获取额外的用户资料数据的地方。使用 OIDC 时,大部分信息已经在 ID 令牌中了。
State 参数#
state 参数防止对 OAuth 回调的 CSRF 攻击。没有它:
- 攻击者在自己的机器上发起 OAuth 流程,得到一个授权码
- 攻击者构造一个 URL:
https://yourapp.com/callback?code=ATTACKER_CODE - 攻击者诱骗受害者点击(邮件链接、隐藏图片)
- 你的应用交换了攻击者的码,把攻击者的 Google 账户绑定到受害者的 session
有了 state:你的应用生成一个随机值,存到 session 中,并包含在授权 URL 中。当回调来时,你验证 state 是否匹配。攻击者无法伪造这个值,因为他们无法访问受害者的 session。
Auth.js(NextAuth)+ Next.js App Router#
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",
},
});Route Handler#
// 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",
],
};在 Server Component 中使用 Session#
// 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>
);
}在 Client Component 中使用 Session#
"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(WebAuthn)#
Passkey 是近年来最重要的认证改进。它们抗钓鱼、抗重放,消除了整类密码相关的漏洞。如果你在 2026 年开始一个新项目,你应该支持 Passkey。
Passkey 如何工作#
Passkey 使用公钥密码学,由生物识别或设备 PIN 支撑:
- 注册:浏览器生成一个密钥对。私钥留在设备上(在安全飞地中,由生物识别保护)。公钥发送到你的服务器。
- 认证:服务器发送一个挑战(随机字节)。设备用私钥签名挑战(在生物识别验证后)。服务器用存储的公钥验证签名。
没有共享密钥通过网络传输。没什么可以钓鱼的,没什么可以泄露的,没什么可以撞库的。
为什么 Passkey 抗钓鱼#
当创建 Passkey 时,它被绑定到_源_(如 https://example.com)。浏览器只会在创建 Passkey 的确切源上使用它。如果攻击者创建了一个看起来很像的站点 https://exarnple.com,Passkey 根本不会被提供。这是由浏览器强制执行的,不依赖用户的警觉性。
这和密码根本不同,用户在钓鱼网站上输入凭据是家常便饭,因为页面看起来没问题。
使用 SimpleWebAuthn 实现#
SimpleWebAuthn 是我推荐的库。它正确处理 WebAuthn 协议,TypeScript 类型也很好。
// 服务端:注册
import {
generateRegistrationOptions,
verifyRegistrationResponse,
} from "@simplewebauthn/server";
import type {
GenerateRegistrationOptionsOpts,
VerifiedRegistrationResponse,
} from "@simplewebauthn/server";
const rpName = "akousa.net";
const rpID = "akousa.net";
const origin = "https://akousa.net";
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");
}
}
}Attestation vs Assertion#
你会遇到的两个术语:
- Attestation(注册):创建新凭证的过程。认证器"证明"其身份和能力。对大多数应用来说,你不需要验证 attestation——设置
attestationType: "none"即可。 - Assertion(认证):使用现有凭证签名挑战的过程。认证器"断言"用户是他们声称的那个人。
MFA 实现#
即使有了 Passkey,你也会遇到需要 TOTP MFA 的场景——Passkey 作为密码之外的第二因素,或者支持设备不支持 Passkey 的用户。
TOTP(基于时间的一次性密码)#
TOTP 是 Google Authenticator、Authy 和 1Password 背后的协议。它的工作方式是:
- 服务器生成一个随机密钥(base32 编码)
- 用户扫描包含密钥的二维码
- 服务器和认证器应用都从密钥和当前时间计算出相同的 6 位数代码
- 代码每 30 秒变化一次
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 = "akousa.net"
): 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 恢复是大多数教程跳过、大多数真实应用搞砸的部分。以下是我实现的方式:
- 首选:来自认证器应用的 TOTP 代码
- 次选:10 个备份码中的一个
- 最后手段:基于邮件的恢复,有 24 小时等待期,并通知用户的其他已验证渠道
等待期至关重要。如果攻击者已经攻破了用户的邮箱,你不希望让他们立即禁用 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 vs ABAC#
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 检查是为了用户体验。服务端检查是为了安全。永远不要只依赖其中一个。
Next.js 中间件中的权限检查#
中间件在每个匹配的请求之前运行。它是做粗粒度访问控制的好地方:
// "这个用户是否被允许访问这个区域?"
// 细粒度检查("这个用户能编辑这篇文章吗?")属于路由处理器
// 因为中间件不容易访问请求体或路由参数。
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,然后诱骗受害者使用那个 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;
}CSRF(跨站请求伪造)#
攻击方式:用户登录了你的站点。他们访问一个恶意页面,该页面向你的站点发起请求。因为 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 中的开放重定向#
攻击方式:攻击者构造一个 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 "/";
}
}通过 Referrer 泄露令牌#
如果你把令牌放在 URL 中(别这样做),它们会通过 Referer header 在用户点击链接时泄露。这造成过真实的泄露,包括 GitHub。
规则:
- 永远不要把令牌放在 URL 查询参数中用于认证
- 设置
Referrer-Policy: strict-origin-when-cross-origin(或更严格的) - 如果你必须把令牌放在 URL 中(比如邮件验证链接),让它们是一次性的且短期有效的
// 在你的 Next.js 中间件或布局中
const headers = new Headers();
headers.set("Referrer-Policy", "strict-origin-when-cross-origin");JWT 密钥注入#
一个不太知名的攻击:一些 JWT 库支持 jwk 或 jku header,告诉验证者去哪里找公钥。攻击者可以:
- 生成自己的密钥对
- 用他们的载荷创建 JWT 并用他们的私钥签名
- 把
jwkheader 设为指向他们的公钥
如果你的库盲目获取并使用 jwk header 中的密钥,签名就会验证通过。修复方法:永远不允许令牌指定自己的验证密钥。始终使用你自己配置中的密钥。
我在 2026 年的认证技术栈#
在多年构建认证系统后,以下是我今天实际使用的。
大多数项目:Auth.js + PostgreSQL + Passkey#
这是我新项目的默认技术栈:
- Auth.js(v5)做重活:OAuth 提供商、session 管理、CSRF、数据库适配器
- PostgreSQL 配合 Prisma 适配器做 session 和账户存储
- Passkey 通过 SimpleWebAuthn 作为新用户的主要登录方式
- 邮箱/密码作为无法使用 Passkey 的用户的备用方案
- TOTP MFA 作为密码登录的第二因素
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,
},
});什么时候用 Clerk 或 Auth0#
我在以下场景下选择托管认证提供商:
- 项目需要企业 SSO(SAML、SCIM)。正确实现 SAML 是好几个月的项目。Clerk 开箱即用。
- 团队没有安全专家。 如果团队里没人能解释 PKCE,他们就不应该从零构建认证。
- 上市时间比成本更重要。 Auth.js 免费但需要几天才能正确设置。Clerk 一下午就行。
- 你需要合规保证(SOC 2、HIPAA)。托管提供商处理合规认证。
托管提供商的权衡:
- 成本:Clerk 按月活跃用户收费。规模大了会累积。
- 供应商锁定:从 Clerk 或 Auth0 迁移出来很痛苦。你的用户表在他们的服务器上。
- 定制限制:如果你的认证流程不寻常,你会和提供商的设计理念打架。
- 延迟:每次认证检查都要调用第三方 API。用数据库 session 的话就是本地查询。
我避免什么#
- 自己造密码学轮子。 我用
jose处理 JWT,@simplewebauthn/server处理 Passkey,bcrypt或argon2处理密码。永远不手写。 - 用 SHA256 存密码。 用 bcrypt(cost factor 12+)或 argon2id。SHA256 太快了——攻击者可以用 GPU 每秒尝试数十亿次哈希。
- 长期有效的访问令牌。 最多 15 分钟。用刷新令牌轮换实现更长的 session。
- 跨服务验证用对称密钥。 如果多个服务需要验证令牌,使用 RS256 的公私钥对。
- 自定义的低熵 session ID。 至少用
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 是新项目的更好选择。
安全清单#
在上线任何认证系统之前,验证以下内容:
- 密码使用 argon2id 或 bcrypt(cost 12+)哈希
- 登录后 session 被重新生成(防止 session 固定)
- Cookie 设置了
HttpOnly、Secure、SameSite=Lax或Strict - JWT 显式指定算法(永远不要信任
algheader) - 访问令牌在 15 分钟或更短时间内过期
- 实现了刷新令牌轮换和重用检测
- OAuth state 参数被验证(CSRF 防护)
- 重定向 URL 针对白名单进行验证
- 登录、注册和密码重置端点应用了速率限制
- 失败的登录尝试带 IP 记录但不记录密码
- N 次失败后锁定账户(渐进式延迟,不是永久锁定)
- 密码重置令牌是一次性的,1 小时内过期
- MFA 备份码像密码一样哈希存储
- CORS 配置为只允许已知源
- 设置了
Referrer-Policyheader - JWT 载荷中没有敏感数据(任何人都能读取)
- WebAuthn 计数器被验证和更新(防止凭证克隆)
这个清单不是详尽的,但它覆盖了我在生产系统中最常见到的漏洞。
总结#
认证是那种技术全景不断演进、但基本原理保持不变的领域:验证身份,签发最小必要的凭证,在每个边界检查权限,假设已被攻破。
2026 年最大的转变是 Passkey 走向主流。浏览器支持是全面的,平台支持(iCloud Keychain、Google 密码管理器)使用户体验无缝,安全属性真正优于我们之前拥有的一切。如果你在构建新应用,让 Passkey 成为你的主要登录方式,把密码当作备用方案。
第二大转变是自己造认证轮子变得越来越难以证明合理性。Auth.js v5、Clerk 和类似的解决方案正确处理了难的部分。唯一自己做的理由是你的需求确实不适合任何现有解决方案——而这比大多数开发者想的要少见。
无论你选什么,像攻击者一样测试你的认证。尝试重放令牌、伪造签名、访问不该访问的路由、操纵重定向 URL。你在上线前发现的 bug 就是不会上新闻的那些。