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),其认证流程如下:
- 注册阶段:服务端发送一个随机 Challenge → 浏览器调用
navigator.credentials.create()→ 用户通过指纹/面容验证 → 设备生成密钥对(私钥存本地,公钥发给服务端) - 认证阶段:服务端发送 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。两者并不冲突,而是互补关系。
核心建议:
- ✅ 立即停止在新项目中使用 Implicit Grant 和密码模式
- ✅ 所有 OAuth 流程强制启用 PKCE(
code_challenge_method=S256) - ✅ 新项目优先支持 Passkeys,同时保留传统登录作为降级
- ✅ 使用旋转 Refresh Token,检测重复使用时自动撤销
- ✅ 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 武装你的应用。