2026 年,身份认证领域正在经历一场静默的革命。Google、Apple、Microsoft 三大平台全面支持 Passkey,GitHub 报告已有超过 35% 的用户启用无密码登录;OAuth 2.1 草案正式合并了 PKCE 为必选流程,淘汰了隐式授权(Implicit Grant)和密码授权(Resource Owner Password)。如果你还在用 2020 年的认证方案,是时候升级了。
本文将从协议原理到生产实战,完整覆盖 OAuth 2.1 + Passkey 的技术栈,帮你构建既安全又用户友好的现代认证系统。
🔐 一、OAuth 2.1 到底变了什么
1.1 从 OAuth 2.0 到 2.1 的关键变更
OAuth 2.1 不是一个全新的协议,而是对 OAuth 2.0(RFC 6749)的安全加固。它合并了多个最佳实践 RFC(RFC 7636 PKCE、RFC 8252 本地应用安全、RFC 6819 威胁模型),形成一个统一的安全基线。
核心变更如下:
| 变更项 | OAuth 2.0 | OAuth 2.1 | 影响 |
|---|---|---|---|
| PKCE | 可选(推荐) | 必选 | 所有授权码流程必须使用 |
| 隐式授权(Implicit) | 支持 | 移除 | SPA 不再用 token 响应类型 |
| 密码授权(ROPC) | 支持 | 移除 | 不再允许直接传用户名密码换 token |
| 刷新令牌轮转 | 可选 | 强制 | 刷新令牌用一次就失效 |
| Redirect URI | 宽松匹配 | 精确匹配 | 禁止通配符和模糊匹配 |
| Bearer Token 使用 | 无限制 | 必须 HTTPS | 明文传输 token 被禁止 |
⚠️ **警告:**如果你的系统还在使用 Implicit Grant 流程(
response_type=token),必须立即迁移。现代浏览器的 Token 存储安全模型已经不支持这种暴露 token 到 URL 的方式。
1.2 PKCE 流程深度解析
PKCE(Proof Key for Code Exchange,读作 “pixy”)是 OAuth 2.1 最重要的安全增强。它的核心目标是防止授权码被拦截后滥用。
工作原理如下:
// PKCE 流程核心实现
import crypto from 'crypto'
// ✅ 正确:生成 PKCE code_verifier 和 code_challenge
function generatePKCE() {
// 1. 生成 43-128 字符的随机字符串作为 code_verifier
const codeVerifier = crypto.randomBytes(32)
.toString('base64url')
// 2. 用 SHA-256 哈希生成 code_challenge
const codeChallenge = crypto.createHash('sha256')
.update(codeVerifier)
.digest('base64url')
return { codeVerifier, codeChallenge }
}
// ❌ 错误:使用明文 challenge(等同于没有 PKCE)
function badPKCE() {
const codeVerifier = 'some-random-string'
// 直接用 verfier 作为 challenge,完全失去保护意义
const codeChallenge = codeVerifier // 千万不要这样做!
return { codeVerifier, codeChallenge }
}
完整授权码 + PKCE 流程:
┌──────────┐ ┌──────────────┐ ┌──────────┐
│ Client │ │ Authorization │ │ Resource │
│ (Browser) │ │ Server │ │ Server │
└─────┬─────┘ └──────┬───────┘ └─────┬─────┘
│ │ │
│ 1. 生成 code_verifier + code_challenge │
│ │ │
│ 2. GET /authorize? │
│ code_challenge=xxx& │
│ code_challenge_method=S256 │
│ ─────────────────────────────>│ │
│ │ │
│ <── 3. 返回授权码 code ────────│ │
│ │ │
│ 4. POST /token (code + code_verifier) │
│ ─────────────────────────────>│ │
│ │ 5. 验证 SHA256(verifier) == challenge │
│ <── 6. 返回 access_token ──────│ │
│ │ │
│ 7. GET /api/resource (Bearer token) │
│ ──────────────────────────────────────────────────────────────> │
│ <── 8. 返回资源数据 ─────────────────────────────────────────────│
📌 记住:PKCE 不仅保护公共客户端(SPA、移动应用),对于机密客户端(服务端应用)同样推荐使用。OAuth 2.1 要求所有客户端都必须实现 PKCE。
🚀 二、Passkey:无密码登录的终极方案
2.1 Passkey 原理与 WebAuthn 协议
Passkey 基于 W3C WebAuthn 标准和 FIDO2 协议,使用非对称加密替代密码。用户注册时,设备生成一对密钥:私钥保存在设备安全芯片(Secure Enclave / TPM),公钥发送到服务器。登录时,服务器发送一个随机挑战(Challenge),设备用私钥签名,服务器用公钥验证。
与传统密码认证的对比:
| 维度 | 密码认证 | Passkey 认证 |
|---|---|---|
| 安全性 | ❌ 可被钓鱼、撞库、暴力破解 | ✅ 抗钓鱼,私钥不出设备 |
| 用户体验 | ❌ 需记忆、输入、定期更换 | ✅ 生物识别一键登录 |
| 服务端存储 | ❌ 需存密码哈希(可被泄露) | ✅ 只存公钥(泄露无风险) |
| 跨设备 | ✅ 任何设备输入密码即可 | ✅ 通过平台同步(iCloud/Google) |
| 实施复杂度 | ✅ 简单 | ⚠️ 中等,需要 WebAuthn API |
| 恢复机制 | ✅ 找回密码 | ⚠️ 需要备用方案 |
⚠️ **警告:**Passkey 不是银弹。必须提供备用认证方式(如恢复码、备用邮箱),否则用户丢失设备就永远无法登录。
2.2 Node.js + WebAuthn 完整实现
以下是一个生产级的 Passkey 注册和认证实现,使用 @simplewebauthn/server 库:
// passkey-server.mjs — Passkey 注册与认证服务端实现
import { generateAuthenticationOptions,
generateRegistrationOptions,
verifyAuthenticationResponse,
verifyRegistrationResponse
} from '@simplewebauthn/server'
const RP_NAME = 'jsjson.com'
const RP_ID = 'jsjson.com'
const ORIGIN = 'https://jsjson.com'
// 模拟数据库存储(生产环境用 Redis/PostgreSQL)
const userStore = new Map() // userId -> { id, username, passkeys }
const challengeStore = new Map() // userId -> challenge
// ========== 注册流程 ==========
// 第一步:生成注册选项
async function startRegistration(userId, username) {
const user = userStore.get(userId) || { id: userId, username, passkeys: [] }
const options = await generateRegistrationOptions({
rpName: RP_NAME,
rpID: RP_ID,
userID: Buffer.from(userId),
userName: username,
userDisplayName: username,
attestationType: 'none', // 不需要设备证明
excludeCredentials: user.passkeys.map(pk => ({
id: pk.credentialID,
type: 'public-key',
})),
authenticatorSelection: {
residentKey: 'preferred', // 优先创建驻留密钥
userVerification: 'preferred',
},
})
// 存储 challenge,后续验证需要
challengeStore.set(userId, options.challenge)
return options
}
// 第二步:验证注册响应
async function finishRegistration(userId, response) {
const expectedChallenge = challengeStore.get(userId)
if (!expectedChallenge) throw new Error('Challenge not found')
const verification = await verifyRegistrationResponse({
response,
expectedChallenge,
expectedOrigin: ORIGIN,
expectedRPID: RP_ID,
})
if (!verification.verified || !verification.registrationInfo) {
throw new Error('Registration verification failed')
}
const { credentialPublicKey, credentialID, counter } = verification.registrationInfo
// 保存 Passkey 到用户记录
const user = userStore.get(userId)
user.passkeys.push({
credentialID,
credentialPublicKey,
counter,
createdAt: new Date(),
})
userStore.set(userId, user)
challengeStore.delete(userId)
return { verified: true }
}
认证流程的实现:
// passkey-auth.mjs — Passkey 认证(登录)流程
async function startAuthentication(userId) {
const user = userStore.get(userId)
if (!user || user.passkeys.length === 0) {
throw new Error('No passkeys registered')
}
const options = await generateAuthenticationOptions({
rpID: RP_ID,
userVerification: 'preferred',
allowCredentials: user.passkeys.map(pk => ({
id: pk.credentialID,
type: 'public-key',
})),
})
challengeStore.set(userId, options.challenge)
return options
}
async function finishAuthentication(userId, response) {
const user = userStore.get(userId)
const expectedChallenge = challengeStore.get(userId)
// 找到匹配的 Passkey
const passkey = user.passkeys.find(
pk => pk.credentialID === response.id
)
if (!passkey) throw new Error('Passkey not found')
const verification = await verifyAuthenticationResponse({
response,
expectedChallenge,
expectedOrigin: ORIGIN,
expectedRPID: RP_ID,
credential: {
id: passkey.credentialID,
publicKey: passkey.credentialPublicKey,
counter: passkey.counter,
},
})
if (!verification.verified) throw new Error('Authentication failed')
// 更新 counter 防重放攻击
passkey.counter = verification.authenticationInfo.newCounter
challengeStore.delete(userId)
// 颁发 JWT 或创建 session
return {
verified: true,
userId: user.id,
token: generateJWT(user.id),
}
}
2.3 前端 WebAuthn 调用
// passkey-client.mjs — 浏览器端 WebAuthn 调用
import {
startRegistration,
startAuthentication,
} from '@simplewebauthn/browser'
// 注册 Passkey
async function registerPasskey() {
// 1. 从服务器获取注册选项
const options = await fetch('/api/passkey/register/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: 'developer@jsjson.com' }),
}).then(r => r.json())
// 2. 调用浏览器 WebAuthn API(弹出生物识别提示)
const attResp = await startRegistration({ optionsJSON: options })
// 3. 将响应发送到服务器验证
const result = await fetch('/api/passkey/register/finish', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(attResp),
}).then(r => r.json())
console.log('Passkey 注册成功:', result.verified)
}
// 使用 Passkey 登录
async function loginWithPasskey() {
const options = await fetch('/api/passkey/auth/start', {
method: 'POST',
}).then(r => r.json())
const authResp = await startAuthentication({ optionsJSON: options })
const result = await fetch('/api/passkey/auth/finish', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(authResp),
}).then(r => r.json())
if (result.verified) {
localStorage.setItem('token', result.token)
window.location.href = '/dashboard'
}
}
💡 提示:
@simplewebauthn/browser库自动处理了浏览器兼容性问题,包括 Safari、Chrome、Firefox 的 WebAuthn API 差异。直接使用原生navigator.credentialsAPI 需要处理大量边界情况。
💡 三、JWT vs Session:认证状态管理之争
3.1 方案对比与选型建议
OAuth 2.1 解决了「如何安全地获取 token」的问题,但获取 token 后如何管理用户状态,仍然需要做出架构决策。
| 维度 | JWT(无状态) | Session(有状态) |
|---|---|---|
| 服务端存储 | ❌ 不需要 | ✅ 需要(Redis/DB) |
| 水平扩展 | ✅ 天然支持 | ⚠️ 需要共享存储 |
| 撤销能力 | ❌ 难以即时撤销 | ✅ 删除 session 即可 |
| 性能 | ✅ 无需查库 | ⚠️ 每次请求查 Redis |
| Token 大小 | ⚠️ 较大(几百字节) | ✅ 仅一个 session ID |
| 安全性 | ⚠️ payload 可见(可签名) | ✅ 敏感数据不暴露 |
| 适用场景 | API、微服务、移动端 | 传统 Web 应用 |
⚡ **关键结论:**不要为了「看起来现代」而盲目选择 JWT。如果你的应用是传统服务端渲染 Web 应用,Session 仍然是更安全、更简单的选择。JWT 适合 API 网关、微服务间通信、移动端认证等场景。
3.2 生产级 JWT 实现
如果你选择了 JWT 方案,以下是安全的实现方式:
// jwt-auth.mjs — 安全的 JWT 实现
import jwt from 'jsonwebtoken'
import crypto from 'crypto'
const JWT_SECRET = process.env.JWT_SECRET || crypto.randomBytes(64).toString('hex')
const ACCESS_TOKEN_TTL = '15m' // 短有效期:15 分钟
const REFRESH_TOKEN_TTL = '7d' // 刷新令牌:7 天
// 生成 token 对
function generateTokenPair(userId, deviceInfo) {
const accessToken = jwt.sign(
{
sub: userId,
type: 'access',
device: deviceInfo.fingerprint,
},
JWT_SECRET,
{
algorithm: 'HS256', // 明确指定算法
expiresIn: ACCESS_TOKEN_TTL,
issuer: 'jsjson.com',
audience: 'jsjson-api',
}
)
const refreshToken = jwt.sign(
{
sub: userId,
type: 'refresh',
jti: crypto.randomUUID(), // 唯一 ID,用于撤销
},
JWT_SECRET,
{
algorithm: 'HS256',
expiresIn: REFRESH_TOKEN_TTL,
issuer: 'jsjson.com',
}
)
return { accessToken, refreshToken }
}
// 验证中间件
function authMiddleware(req, res, next) {
const authHeader = req.headers.authorization
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Missing token' })
}
try {
const token = authHeader.slice(7)
const payload = jwt.verify(token, JWT_SECRET, {
algorithms: ['HS256'], // 防止算法替换攻击
issuer: 'jsjson.com',
audience: 'jsjson-api',
})
req.userId = payload.sub
next()
} catch (err) {
if (err.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Token expired', code: 'TOKEN_EXPIRED' })
}
return res.status(401).json({ error: 'Invalid token' })
}
}
⚠️ **警告:**永远不要在 JWT payload 中存放敏感信息(密码、手机号等)。JWT 的 payload 只是 Base64 编码,不是加密。任何人拿到 token 都能解码读取内容,虽然无法篡改(因为有签名)。
🔧 四、认证架构最佳实践与避坑指南
4.1 推荐的认证架构
对于 2026 年的新项目,推荐采用分层认证架构:
- Web 应用(SSR):Session + HttpOnly Cookie
- SPA 前端:BFF(Backend For Frontend)模式,前端不直接持有 token
- 移动端:OAuth 2.1 Authorization Code + PKCE
- API 间通信:mTLS 或 JWT + 短有效期
- 用户友好:Passkey 作为首选,密码作为备用
4.2 常见安全坑点
坑点 1:Token 存储位置
// ❌ 错误:将 token 存在 localStorage(XSS 可窃取)
localStorage.setItem('accessToken', token)
// ✅ 正确:使用 HttpOnly Cookie(JS 无法读取)
res.cookie('token', token, {
httpOnly: true, // JS 无法访问
secure: true, // 仅 HTTPS
sameSite: 'strict', // 防 CSRF
maxAge: 15 * 60 * 1000,
})
坑点 2:刷新令牌轮转
// ❌ 错误:刷新令牌永不过期
const refreshToken = jwt.sign({ sub: userId }, secret, { expiresIn: '100y' })
// ✅ 正确:刷新令牌一次使用后轮转
async function refreshAccessToken(oldRefreshToken) {
const payload = jwt.verify(oldRefreshToken, secret)
// 立即废弃旧的刷新令牌
await redis.del(`refresh:${payload.jti}`)
// 生成新的 token 对
const tokens = generateTokenPair(payload.sub, {})
// 存储新刷新令牌的 jti
await redis.set(`refresh:${newPayload.jti}`, '1', 'EX', 7 * 86400)
return tokens
}
坑点 3:Redirect URI 验证
// ❌ 错误:使用正则模糊匹配 redirect_uri
const isValid = /jsjson\.com/.test(redirectUri) // 可被 evil-jsjson.com 绕过
// ✅ 正确:精确匹配白名单
const ALLOWED_REDIRECTS = [
'https://jsjson.com/callback',
'https://jsjson.com/auth/callback',
]
if (!ALLOWED_REDIRECTS.includes(redirectUri)) {
throw new Error('Invalid redirect_uri')
}
4.3 安全检查清单
| 检查项 | 状态 | 说明 |
|---|---|---|
| 强制 HTTPS | ✅ 必须 | 所有认证端点必须 HTTPS |
| PKCE | ✅ 必须 | OAuth 2.1 强制要求 |
| Token 短有效期 | ✅ 必须 | Access Token ≤ 15 分钟 |
| 刷新令牌轮转 | ✅ 必须 | 用一次换一次 |
| CORS 限制 | ✅ 必须 | 不要用 * 通配 |
| Rate Limiting | ✅ 必须 | 登录接口限流防暴力破解 |
| CSRF 防护 | ✅ 必须 | Cookie 认证必须防 CSRF |
| 安全头 | ✅ 推荐 | CSP、X-Content-Type-Options |
| Passkey 备用方案 | ✅ 推备 | 提供恢复码或备用认证 |
| 审计日志 | ✅ 推荐 | 记录所有认证事件 |
📊 总结
OAuth 2.1 和 Passkey 代表了身份认证的两个方向:OAuth 2.1 通过移除不安全的授权方式和强制 PKCE,让「授权」变得更安全;Passkey 通过非对称加密和生物识别,让「认证」变得更简单。两者结合,可以构建一个既安全又用户友好的认证系统。
迁移建议:
- ✅ 新项目直接采用 OAuth 2.1 + Passkey + 短期 JWT
- ✅ 老项目逐步迁移:先加 PKCE,再移除 Implicit Grant,最后引入 Passkey
- ❌ 不要在新项目中使用 Implicit Grant 或 Resource Owner Password 流程
- ❌ 不要将 JWT 当作万能方案,传统 Web 应用用 Session 更合适
推荐工具与库:
- 🔧 SimpleWebAuthn — 最好用的 WebAuthn 库
- 🔧 jose — 现代 JWT 库(支持 JOSE/JWK/JWE)
- 🔧 arctic — 轻量 OAuth 2.0 客户端
- 🔧 lucia — 全栈认证框架
- 🔧 Passkeys.dev — Passkey 开发者资源