跳至内容
·14 分钟阅读

2026 年的现代认证:JWT、Session、OAuth 与 Passkey

完整的认证技术全景:Session 与 JWT 怎么选、OAuth 2.0/OIDC 流程、刷新令牌轮换、Passkey(WebAuthn),以及我在 Next.js 中实际使用的认证模式。

分享:X / TwitterLinkedIn

认证是 Web 开发中唯一一个"能用"永远不够好的领域。日期选择器的 bug 让人烦躁,认证系统的 bug 就是数据泄露。

我从零实现过认证系统,在不同提供商之间迁移过,调试过令牌被盗事件,也承受过"安全以后再修"这种决策的后果。这篇文章是我希望自己入门时就能读到的全面指南。不仅仅是理论——还有实际的权衡、真实的漏洞,以及在生产压力下经受住考验的模式。

我们将覆盖完整的技术全景:Session、JWT、OAuth 2.0、Passkey、MFA 和授权。读完后,你不仅会理解每种机制是_怎么_工作的,还会理解_什么时候_用它以及_为什么_替代方案存在。

Session vs JWT:真正的权衡#

这是你面对的第一个决策,互联网上充满了关于它的糟糕建议。让我讲讲真正重要的东西。

基于 Session 的认证#

Session 是最原始的方式。服务器创建一条 session 记录,存到某个地方(数据库、Redis、内存),然后通过 cookie 给客户端一个不透明的 session ID。

typescript
// 简化的 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 状态,而是把它编码到一个签名令牌中,由客户端持有。

typescript
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 — 声明算法和令牌类型:

json
{
  "alg": "RS256",
  "typ": "JWT"
}

Payload — 包含声明。标准声明使用短名称:

json
{
  "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)— 非对称的。私钥签名,公钥验证。只有认证服务器需要私钥。任何服务都能用公钥验证。如果一个验证服务被攻破,攻击者只能读取令牌但不能伪造。

typescript
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 库曾经会:

  1. 从 header 读取 alg 字段
  2. 使用它指定的任何算法
  3. 如果 alg: "none",完全跳过签名验证

攻击者可以拿一个有效的 JWT,修改载荷(比如设置 "role": "admin"),把 alg 设为 "none",移除签名,然后发送。服务器会接受它。

typescript
// 有漏洞 — 永远不要这样做
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));
  }
 
  // ... 验证签名
}

修复很简单:始终显式指定期望的算法。 永远不要让令牌告诉你如何验证它。

typescript
// 安全 — 算法是硬编码的,不是从令牌读取的
const { payload } = await jwtVerify(token, key, {
  algorithms: ["RS256"], // 只接受 RS256 — 忽略 header
});

现代库如 jose 默认正确处理这个问题,但你仍然应该显式传入 algorithms 选项作为深度防御。

算法混淆攻击#

和上面相关:如果服务器配置为接受 RS256,攻击者可能会:

  1. 获取服务器的公钥(毕竟它是公开的)
  2. 创建一个 alg: "HS256" 的令牌
  3. 用公钥作为 HMAC 密钥来签名

如果服务器读取 alg header 并切换到 HS256 验证,公钥(所有人都知道的)就变成了共享密钥。签名是有效的。攻击者伪造了一个令牌。

同样,修复方法一样:永远不要信任令牌 header 中的算法。始终硬编码。

刷新令牌轮换#

如果你使用 JWT,你需要一个刷新令牌策略。发送长期有效的访问令牌是在自找麻烦——如果它被盗,攻击者在整个有效期内都有访问权限。

模式如下:

  • 访问令牌:短期有效(15 分钟)。用于 API 请求。
  • 刷新令牌:长期有效(30 天)。仅用于获取新的访问令牌。
typescript
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 };
}

每次使用时轮换#

每次客户端使用刷新令牌获取新的访问令牌时,你签发一个新的刷新令牌并使旧的失效:

typescript
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 };
}

为什么家族失效很重要#

考虑这个场景:

  1. 用户登录,获得刷新令牌 A
  2. 攻击者窃取了刷新令牌 A
  3. 攻击者用 A 获取新的令牌对(访问令牌 + 刷新令牌 B)
  4. 用户试图用 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)最初是为移动应用设计的,但现在推荐所有客户端使用,包括服务端应用。

typescript
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();
}
typescript
// 第二步:处理回调
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 提供商都暴露这些:

  1. 授权端点 — 你重定向用户去登录和授权的地方。返回授权码。
  2. 令牌端点 — 你的服务器用授权码交换访问/刷新/ID 令牌的地方。这是服务器到服务器的调用。
  3. 用户信息端点 — 你可以用访问令牌获取额外的用户资料数据的地方。使用 OIDC 时,大部分信息已经在 ID 令牌中了。

State 参数#

state 参数防止对 OAuth 回调的 CSRF 攻击。没有它:

  1. 攻击者在自己的机器上发起 OAuth 流程,得到一个授权码
  2. 攻击者构造一个 URL:https://yourapp.com/callback?code=ATTACKER_CODE
  3. 攻击者诱骗受害者点击(邮件链接、隐藏图片)
  4. 你的应用交换了攻击者的码,把攻击者的 Google 账户绑定到受害者的 session

有了 state:你的应用生成一个随机值,存到 session 中,并包含在授权 URL 中。当回调来时,你验证 state 是否匹配。攻击者无法伪造这个值,因为他们无法访问受害者的 session。

Auth.js(NextAuth)+ Next.js App Router#

Auth.js 是我在大多数 Next.js 项目中首选的工具。它处理 OAuth 流程、session 管理、数据库持久化和 CSRF 保护。以下是一个生产就绪的配置。

基础配置#

typescript
// 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#

typescript
// src/app/api/auth/[...nextauth]/route.ts
import { handlers } from "@/lib/auth";
 
export const { GET, POST } = handlers;

中间件保护#

typescript
// 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#

typescript
// 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#

typescript
"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 支撑:

  1. 注册:浏览器生成一个密钥对。私钥留在设备上(在安全飞地中,由生物识别保护)。公钥发送到你的服务器。
  2. 认证:服务器发送一个挑战(随机字节)。设备用私钥签名挑战(在生物识别验证后)。服务器用存储的公钥验证签名。

没有共享密钥通过网络传输。没什么可以钓鱼的,没什么可以泄露的,没什么可以撞库的。

为什么 Passkey 抗钓鱼#

当创建 Passkey 时,它被绑定到_源_(如 https://example.com)。浏览器只会在创建 Passkey 的确切源上使用它。如果攻击者创建了一个看起来很像的站点 https://exarnple.com,Passkey 根本不会被提供。这是由浏览器强制执行的,不依赖用户的警觉性。

这和密码根本不同,用户在钓鱼网站上输入凭据是家常便饭,因为页面看起来没问题。

使用 SimpleWebAuthn 实现#

SimpleWebAuthn 是我推荐的库。它正确处理 WebAuthn 协议,TypeScript 类型也很好。

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 };
}
typescript
// 服务端:认证
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 };
}
typescript
// 客户端:注册
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 背后的协议。它的工作方式是:

  1. 服务器生成一个随机密钥(base32 编码)
  2. 用户扫描包含密钥的二维码
  3. 服务器和认证器应用都从密钥和当前时间计算出相同的 6 位数代码
  4. 代码每 30 秒变化一次
typescript
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`;
}
typescript
// 验证 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 设置时始终生成备份码:

typescript
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 恢复是大多数教程跳过、大多数真实应用搞砸的部分。以下是我实现的方式:

  1. 首选:来自认证器应用的 TOTP 代码
  2. 次选:10 个备份码中的一个
  3. 最后手段:基于邮件的恢复,有 24 小时等待期,并通知用户的其他已验证渠道

等待期至关重要。如果攻击者已经攻破了用户的邮箱,你不希望让他们立即禁用 MFA。24 小时的延迟给了合法用户注意到邮件并进行干预的时间。

typescript
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(基于角色的访问控制):用户有角色,角色有权限。简单,容易推理,适用于大多数应用。

typescript
// 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(基于属性的访问控制):权限取决于用户、资源和上下文的属性。更灵活但也更复杂。

typescript
// 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 层。

typescript
// 差 — 只在组件中检查
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 中间件中的权限检查#

中间件在每个匹配的请求之前运行。它是做粗粒度访问控制的好地方:

typescript
// "这个用户是否被允许访问这个区域?"
// 细粒度检查("这个用户能编辑这篇文章吗?")属于路由处理器
// 因为中间件不容易访问请求体或路由参数。
 
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。

typescript
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 场景。

typescript
// 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 中可能还带有令牌。

typescript
// 有漏洞
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 中(比如邮件验证链接),让它们是一次性的且短期有效的
typescript
// 在你的 Next.js 中间件或布局中
const headers = new Headers();
headers.set("Referrer-Policy", "strict-origin-when-cross-origin");

JWT 密钥注入#

一个不太知名的攻击:一些 JWT 库支持 jwkjku header,告诉验证者去哪里找公钥。攻击者可以:

  1. 生成自己的密钥对
  2. 用他们的载荷创建 JWT 并用他们的私钥签名
  3. jwk header 设为指向他们的公钥

如果你的库盲目获取并使用 jwk header 中的密钥,签名就会验证通过。修复方法:永远不允许令牌指定自己的验证密钥。始终使用你自己配置中的密钥。

我在 2026 年的认证技术栈#

在多年构建认证系统后,以下是我今天实际使用的。

大多数项目:Auth.js + PostgreSQL + Passkey#

这是我新项目的默认技术栈:

  • Auth.js(v5)做重活:OAuth 提供商、session 管理、CSRF、数据库适配器
  • PostgreSQL 配合 Prisma 适配器做 session 和账户存储
  • Passkey 通过 SimpleWebAuthn 作为新用户的主要登录方式
  • 邮箱/密码作为无法使用 Passkey 的用户的备用方案
  • TOTP MFA 作为密码登录的第二因素

Session 策略是数据库支撑的(不是 JWT),这给了我即时撤销和简单的 session 管理。

typescript
// 这是我新项目的典型 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,bcryptargon2 处理密码。永远不手写。
  • 用 SHA256 存密码。 用 bcrypt(cost factor 12+)或 argon2id。SHA256 太快了——攻击者可以用 GPU 每秒尝试数十亿次哈希。
  • 长期有效的访问令牌。 最多 15 分钟。用刷新令牌轮换实现更长的 session。
  • 跨服务验证用对称密钥。 如果多个服务需要验证令牌,使用 RS256 的公私钥对。
  • 自定义的低熵 session ID。 至少用 crypto.randomBytes(32)。UUID v4 可以接受但熵比原始随机字节少。

密码哈希:正确的做法#

既然提到了——以下是 2026 年正确哈希密码的方式:

typescript
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 设置了 HttpOnlySecureSameSite=LaxStrict
  • JWT 显式指定算法(永远不要信任 alg header)
  • 访问令牌在 15 分钟或更短时间内过期
  • 实现了刷新令牌轮换和重用检测
  • OAuth state 参数被验证(CSRF 防护)
  • 重定向 URL 针对白名单进行验证
  • 登录、注册和密码重置端点应用了速率限制
  • 失败的登录尝试带 IP 记录但不记录密码
  • N 次失败后锁定账户(渐进式延迟,不是永久锁定)
  • 密码重置令牌是一次性的,1 小时内过期
  • MFA 备份码像密码一样哈希存储
  • CORS 配置为只允许已知源
  • 设置了 Referrer-Policy header
  • JWT 载荷中没有敏感数据(任何人都能读取)
  • WebAuthn 计数器被验证和更新(防止凭证克隆)

这个清单不是详尽的,但它覆盖了我在生产系统中最常见到的漏洞。

总结#

认证是那种技术全景不断演进、但基本原理保持不变的领域:验证身份,签发最小必要的凭证,在每个边界检查权限,假设已被攻破。

2026 年最大的转变是 Passkey 走向主流。浏览器支持是全面的,平台支持(iCloud Keychain、Google 密码管理器)使用户体验无缝,安全属性真正优于我们之前拥有的一切。如果你在构建新应用,让 Passkey 成为你的主要登录方式,把密码当作备用方案。

第二大转变是自己造认证轮子变得越来越难以证明合理性。Auth.js v5、Clerk 和类似的解决方案正确处理了难的部分。唯一自己做的理由是你的需求确实不适合任何现有解决方案——而这比大多数开发者想的要少见。

无论你选什么,像攻击者一样测试你的认证。尝试重放令牌、伪造签名、访问不该访问的路由、操纵重定向 URL。你在上线前发现的 bug 就是不会上新闻的那些。

相关文章