OAuth 2.1 + PKCE + Passkeys:2026 年现代 Web 认证完全指南

深入解析 OAuth 2.1 新标准、PKCE 流程、Passkeys 无密码认证,包含完整代码示例与方案对比,助你构建安全可靠的现代认证系统。

安全与密码 2026-05-30 15 分钟

2025 年底,OAuth 2.1 正式合并了 RFC 6749(OAuth 2.0)的多个扩展,废弃了隐式授权(Implicit Grant)和密码模式(Resource Owner Password),同时将 PKCE 提升为所有授权流程的必选项。与此同时,FIDO Alliance 数据显示,截至 2026 年 Q1,全球已有超过 15 亿个 Passkeys 被注册,Google、Apple、Microsoft 三大平台全面支持跨设备同步。这两个趋势正在从根本上改变 Web 应用的认证架构——如果你还在用 2020 年的认证方案,是时候升级了。

🔐 一、OAuth 2.1 的核心变化与 PKCE 实战

OAuth 2.1 不是一个全新的协议,而是对 OAuth 2.0 生态的「大扫除」。它把过去几年被证明安全的扩展(PKCE、Token Revocation、Bearer Token Usage)整合进核心规范,同时砍掉了有安全隐患的旧模式。

📌 OAuth 2.1 废弃了什么?

最显著的变化是两个授权流程被正式移除:

  • 隐式授权(Implicit Grant):Token 直接暴露在 URL Fragment 中,容易被中间人攻击和日志泄露
  • 密码模式(Resource Owner Password Credentials):客户端直接接触用户密码,违背最小权限原则
  • 不带 PKCE 的授权码流程:即使是授权码模式,现在也必须使用 PKCE

⚠️ **警告:**如果你的项目还在使用 Implicit Grant,请立即迁移。OAuth 2.1 规范明确表示该模式存在不可修复的安全缺陷,所有新项目禁止使用。

🔧 PKCE 完整实现

PKCE(Proof Key for Code Exchange,发音 “pixy”)的核心思想很简单:在授权请求时发送一个随机生成的 code_verifier 的哈希值(code_challenge),换取 Token 时再出示原始的 code_verifier,服务端验证哈希是否匹配。

以下是完整的前端实现:

// PKCE 工具函数:生成 code_verifier 和 code_challenge
function generateCodeVerifier() {
  const array = new Uint8Array(32);
  crypto.getRandomValues(array);
  return base64UrlEncode(array);
}

function base64UrlEncode(buffer) {
  return btoa(String.fromCharCode(...new Uint8Array(buffer)))
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=+$/, '');
}

async function generateCodeChallenge(verifier) {
  const encoder = new TextEncoder();
  const data = encoder.encode(verifier);
  const digest = await crypto.subtle.digest('SHA-256', data);
  return base64UrlEncode(digest);
}

// 发起 OAuth 2.1 授权请求
async function startOAuthFlow() {
  const codeVerifier = generateCodeVerifier();
  const codeChallenge = await generateCodeChallenge(codeVerifier);
  const state = crypto.randomUUID();

  // 存储到 sessionStorage,回调时验证
  sessionStorage.setItem('pkce_verifier', codeVerifier);
  sessionStorage.setItem('oauth_state', state);

  const params = new URLSearchParams({
    response_type: 'code',
    client_id: 'your-client-id',
    redirect_uri: 'https://app.example.com/callback',
    scope: 'openid profile email',
    state: state,
    code_challenge: codeChallenge,
    code_challenge_method: 'S256',
  });

  window.location.href = `https://auth.example.com/authorize?${params}`;
}

// 处理回调,用 code 换取 Token
async function handleCallback() {
  const params = new URLSearchParams(window.location.search);
  const code = params.get('code');
  const state = params.get('state');

  // 验证 state 防止 CSRF
  if (state !== sessionStorage.getItem('oauth_state')) {
    throw new Error('State mismatch — possible CSRF attack');
  }

  const codeVerifier = sessionStorage.getItem('pkce_verifier');

  const response = await fetch('https://auth.example.com/token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'authorization_code',
      code: code,
      redirect_uri: 'https://app.example.com/callback',
      client_id: 'your-client-id',
      code_verifier: codeVerifier,  // 关键:出示原始 verifier
    }),
  });

  const tokens = await response.json();
  // tokens: { access_token, refresh_token, expires_in, token_type }
  sessionStorage.removeItem('pkce_verifier');
  sessionStorage.removeItem('oauth_state');
  return tokens;
}

💡 提示:code_challenge_method 必须使用 S256(SHA-256),不要用 plain。虽然规范允许 plain,但在生产环境中明文传输 verifier 等于没有 PKCE。

📊 OAuth 2.1 vs 2.0 关键对比

特性 OAuth 2.0 OAuth 2.1 推荐
隐式授权(Implicit) ✅ 支持 ❌ 废弃 必须迁移
密码模式(ROPC) ✅ 支持 ❌ 废弃 必须迁移
PKCE 可选(仅公共客户端) 强制所有客户端 必须实现
Bearer Token 位置 Header 或 Query 仅 Header ✅ 推荐
Refresh Token 限制 无明确要求 必须可撤销 + 一次使用 ✅ 推荐
Redirect URI 匹配 允许模糊匹配 必须精确匹配 ✅ 推荐

🔑 二、Passkeys:告别密码的时代

Passkeys 是 FIDO2/WebAuthn 标准的商业化落地,它的核心价值在于:用非对称密钥对替代密码,用生物识别替代短信验证码。用户不再需要记住任何密码,也不用担心数据库泄露导致的密码外泄。

🧠 Passkeys 的工作原理

Passkeys 的底层是 WebAuthn(Web Authentication API),其认证流程如下:

  1. 注册阶段:服务端发送一个随机 Challenge → 浏览器调用 navigator.credentials.create() → 用户通过指纹/面容验证 → 设备生成密钥对(私钥存本地,公钥发给服务端)
  2. 认证阶段:服务端发送 Challenge → 浏览器调用 navigator.credentials.get() → 用户生物识别验证 → 设备用私钥签名 Challenge → 服务端用公钥验签

📌 **记住:**Passkeys 的私钥永远不会离开用户设备(或通过 iCloud/Google Password Manager 跨设备同步)。即使服务端被攻破,攻击者拿到的也只是公钥,无法伪造登录。

🔧 Passkeys 完整实现

以下是一个完整的 Passkeys 注册和认证实现,包含前后端代码:

// ==================== 前端:注册 Passkey ====================
async function registerPasskey(username) {
  // 1. 从服务端获取注册选项
  const optionsRes = await fetch('/api/webauthn/register/options', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ username }),
  });
  const options = await optionsRes.json();

  // 2. 调用浏览器 WebAuthn API 创建凭证
  const credential = await navigator.credentials.create({
    publicKey: {
      challenge: base64ToBuffer(options.challenge),
      rp: { name: 'jsjson.com', id: 'jsjson.com' },
      user: {
        id: base64ToBuffer(options.userId),
        name: username,
        displayName: username,
      },
      pubKeyCredParams: [
        { type: 'public-key', alg: -7 },   // ES256
        { type: 'public-key', alg: -257 }, // RS256
      ],
      authenticatorSelection: {
        residentKey: 'preferred',
        userVerification: 'required', // 要求生物识别
      },
      timeout: 60000,
    },
  });

  // 3. 将凭证信息发送到服务端验证并存储
  const verifyRes = await fetch('/api/webauthn/register/verify', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      id: credential.id,
      rawId: bufferToBase64(credential.rawId),
      type: credential.type,
      response: {
        attestationObject: bufferToBase64(credential.response.attestationObject),
        clientDataJSON: bufferToBase64(credential.response.clientDataJSON),
      },
    }),
  });

  return verifyRes.ok;
}

// ==================== 前端:Passkey 登录 ====================
async function loginWithPasskey() {
  // 1. 从服务端获取认证选项
  const optionsRes = await fetch('/api/webauthn/authenticate/options', {
    method: 'POST',
  });
  const options = await optionsRes.json();

  // 2. 调用浏览器 WebAuthn API 获取凭证
  const assertion = await navigator.credentials.get({
    publicKey: {
      challenge: base64ToBuffer(options.challenge),
      rpId: 'jsjson.com',
      userVerification: 'required',
      timeout: 60000,
    },
  });

  // 3. 将签名发送到服务端验证
  const verifyRes = await fetch('/api/webauthn/authenticate/verify', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      id: assertion.id,
      rawId: bufferToBase64(assertion.rawId),
      type: assertion.type,
      response: {
        authenticatorData: bufferToBase64(assertion.response.authenticatorData),
        clientDataJSON: bufferToBase64(assertion.response.clientDataJSON),
        signature: bufferToBase64(assertion.response.signature),
      },
    }),
  });

  const result = await verifyRes.json();
  return result; // { accessToken, refreshToken, user }
}

// 工具函数
function base64ToBuffer(base64) {
  const binary = atob(base64.replace(/-/g, '+').replace(/_/g, '/'));
  return Uint8Array.from(binary, c => c.charCodeAt(0)).buffer;
}

function bufferToBase64(buffer) {
  return btoa(String.fromCharCode(...new Uint8Array(buffer)))
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=+$/, '');
}
// ==================== Node.js 后端:使用 @simplewebauthn/server ====================
import {
  generateRegistrationOptions,
  verifyRegistrationResponse,
  generateAuthenticationOptions,
  verifyAuthenticationResponse,
} from '@simplewebauthn/server';

const RP_NAME = 'jsjson.com';
const RP_ID = 'jsjson.com';
const EXPECTED_ORIGIN = 'https://jsjson.com';

// 注册:生成选项
app.post('/api/webauthn/register/options', async (req, res) => {
  const { username } = req.body;
  const user = await db.findOrCreateUser(username);
  const existingCredentials = await db.getUserCredentials(user.id);

  const options = generateRegistrationOptions({
    rpName: RP_NAME,
    rpID: RP_ID,
    userID: Buffer.from(user.id),
    userName: username,
    userDisplayName: username,
    excludeCredentials: existingCredentials.map(cred => ({
      id: cred.credentialID,
      type: 'public-key',
    })),
    authenticatorSelection: {
      residentKey: 'preferred',
      userVerification: 'required',
    },
  });

  // 存储 challenge 到 session,后续验证用
  req.session.challenge = options.challenge;
  res.json(options);
});

// 注册:验证响应
app.post('/api/webauthn/register/verify', async (req, res) => {
  const { id, rawId, response } = req.body;

  const verification = await verifyRegistrationResponse({
    response: req.body,
    expectedChallenge: req.session.challenge,
    expectedOrigin: EXPECTED_ORIGIN,
    expectedRPID: RP_ID,
  });

  if (verification.verified && verification.registrationInfo) {
    const { credentialPublicKey, credentialID, counter } = verification.registrationInfo;
    await db.saveCredential({
      userId: req.session.userId,
      credentialID: Buffer.from(credentialID).toString('base64url'),
      credentialPublicKey: Buffer.from(credentialPublicKey).toString('base64url'),
      counter,
    });
  }

  res.json({ verified: verification.verified });
});

📊 认证方式安全性对比

认证方式 防钓鱼 防重放 防泄露 用户体验 推荐
密码 + 短信验证码 😞 差 ❌ 不推荐
密码 + TOTP 二步验证 😐 一般 ⚠️ 过渡方案
密码 + 硬件密钥(YubiKey) 😐 一般 ✅ 推荐
Passkeys 😊 优秀 ✅✅ 强烈推荐
OAuth 2.1 + PKCE 😊 好 ✅ 推荐(第三方登录)

🏗️ 三、生产环境架构与避坑指南

在实际项目中,认证系统远不止「登录成功返回 Token」这么简单。你需要考虑 Token 刷新、多设备管理、降级方案等一系列工程问题。

🔄 Refresh Token 旋转策略

OAuth 2.1 要求 Refresh Token 必须支持撤销(Revocation),且推荐使用一次性的旋转刷新令牌(Rotating Refresh Token)。这意味着每次用 Refresh Token 换取新的 Access Token 时,旧的 Refresh Token 立即失效,同时发放一个新的 Refresh Token。

// Token 刷新管理器:实现 Refresh Token 旋转
class TokenManager {
  #accessToken = null;
  #refreshToken = null;
  #refreshPromise = null; // 防止并发刷新

  constructor(apiBaseUrl) {
    this.apiBaseUrl = apiBaseUrl;
  }

  async getAccessToken() {
    if (this.#isTokenValid()) {
      return this.#accessToken;
    }
    return this.#refresh();
  }

  #isTokenValid() {
    if (!this.#accessToken) return false;
    try {
      const payload = JSON.parse(atob(this.#accessToken.split('.')[1]));
      // 提前 30 秒判定过期,避免边界情况
      return payload.exp * 1000 > Date.now() + 30_000;
    } catch {
      return false;
    }
  }

  async #refresh() {
    // 并发保护:多个请求同时刷新时只发一次请求
    if (this.#refreshPromise) {
      return this.#refreshPromise;
    }

    this.#refreshPromise = this.#doRefresh();

    try {
      return await this.#refreshPromise;
    } finally {
      this.#refreshPromise = null;
    }
  }

  async #doRefresh() {
    if (!this.#refreshToken) {
      throw new Error('No refresh token — user must re-authenticate');
    }

    const response = await fetch(`${this.apiBaseUrl}/token`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: new URLSearchParams({
        grant_type: 'refresh_token',
        refresh_token: this.#refreshToken,
      }),
    });

    if (!response.ok) {
      // Refresh Token 已被撤销(可能被盗用),清除所有状态
      this.#clearTokens();
      throw new Error('Refresh token revoked — possible token theft');
    }

    const data = await response.json();
    this.#accessToken = data.access_token;
    this.#refreshToken = data.refresh_token; // 旋转:新 token
    return this.#accessToken;
  }

  setTokens(accessToken, refreshToken) {
    this.#accessToken = accessToken;
    this.#refreshToken = refreshToken;
  }

  #clearTokens() {
    this.#accessToken = null;
    this.#refreshToken = null;
  }
}

// 使用示例
const tokenManager = new TokenManager('https://auth.example.com');
tokenManager.setTokens(tokens.access_token, tokens.refresh_token);

// 所有 API 请求自动获取有效 Token
async function apiFetch(url, options = {}) {
  const token = await tokenManager.getAccessToken();
  return fetch(url, {
    ...options,
    headers: {
      ...options.headers,
      Authorization: `Bearer ${token}`,
    },
  });
}

⚠️ **警告:**如果检测到 Refresh Token 被重复使用(即同一个 Refresh Token 出现两次请求),服务端应该撤销该用户的所有 Token 并强制重新登录。这是检测 Token 泄漏的关键信号。

⚠️ 常见坑点与避坑指南

坑点 1:Passkeys 的跨平台同步问题

Passkeys 依赖平台的凭证同步机制(iCloud Keychain、Google Password Manager、Windows Hello)。如果用户在 iPhone 上注册了 Passkey,想在 Windows PC 上登录,需要通过「跨设备认证」(Cross-Device Authentication)扫描 QR 码。

  • ✅ 正确做法:同时支持 Passkeys 和传统登录方式作为降级方案
  • ❌ 错误做法:强制只用 Passkeys,不提供任何替代方案

坑点 2:JWT 存储位置争议

Access Token 应该存在哪里?这是前端认证最经典的争论。

  • ✅ 推荐:存在内存中(JavaScript 变量),页面刷新时用 Refresh Token 重新获取
  • ⚠️ 可接受:存在 httpOnly + Secure + SameSite=Strict 的 Cookie 中
  • ❌ 绝对不要:存在 localStorage 中(易受 XSS 攻击)

坑点 3:Redirect URI 的安全配置

OAuth 2.1 要求 Redirect URI 必须精确匹配,不允许通配符或模糊匹配。

  • ✅ 正确:https://app.example.com/callback
  • ❌ 错误:https://*.example.com/callback(通配符)
  • ❌ 错误:http://localhost:3000/callback(非 HTTPS,仅限开发环境)

🎯 架构选型建议

根据应用类型选择合适的认证方案:

  • 单页应用(SPA):OAuth 2.1 Authorization Code + PKCE + Refresh Token 旋转,Access Token 存内存
  • 移动应用:OAuth 2.1 + PKCE + 系统浏览器(不使用 WebView),配合 Passkeys 实现无密码登录
  • 服务端渲染(SSR):BFF(Backend For Frontend)模式,Token 存在服务端 Session,前端只持有 Session Cookie
  • 内部系统 / B 端:Passkeys 作为主要认证方式,降低密码管理成本
  • ❌ 不推荐:自行实现加密算法或 Token 生成逻辑,使用成熟的库(jose@simplewebauthn/server

📝 总结

OAuth 2.1 和 Passkeys 代表了 Web 认证的两个明确方向:对外统一身份认证用 OAuth 2.1 + PKCE,对内无密码认证用 Passkeys。两者并不冲突,而是互补关系。

核心建议:

  1. ✅ 立即停止在新项目中使用 Implicit Grant 和密码模式
  2. ✅ 所有 OAuth 流程强制启用 PKCE(code_challenge_method=S256
  3. ✅ 新项目优先支持 Passkeys,同时保留传统登录作为降级
  4. ✅ 使用旋转 Refresh Token,检测重复使用时自动撤销
  5. ✅ Access Token 存内存,不要存 localStorage

推荐工具和库:

  • 🔧 jose:轻量级 JWT/JWE/JWS 库,浏览器和 Node.js 通用
  • 🔧 @simplewebauthn/server + @simplewebauthn/browser:WebAuthn 最佳实现
  • 🔧 openid-client:OpenID Connect 认证客户端
  • 🔧 arctic:Arctic 出品的轻量 OAuth 2.0 客户端,支持 50+ Provider
  • 🔧 jsjson.com 在线工具:JWT 解析、Base64 编解码、SHA-256 哈希等辅助工具

认证系统的安全没有「差不多就行」——每一个疏漏都是一个攻击面。从今天开始,用 OAuth 2.1 + Passkeys 武装你的应用。

📚 相关文章