WebAuthn 与 Passkeys 无密码认证实战:从零构建安全的身份验证系统

深入讲解 WebAuthn/FIDO2 无密码认证原理与实战,涵盖注册认证流程、公钥密码学、设备绑定策略、服务端验证实现,附完整 TypeScript 代码与安全对比数据。

安全与密码 2026-06-08 14 分钟

2026 年,Google、Apple、Microsoft 三大平台全面支持 Passkeys,全球超过 15 亿个活跃账户已启用无密码登录。传统的「密码 + 短信验证码」模式正在被 WebAuthn 取代——不是因为潮流,而是因为密码泄露导致的数据泄露事件占所有安全事件的 80% 以上,而 WebAuthn 从根本上消除了密码这个攻击面。如果你还在用 bcrypt 哈希存储密码,这篇文章会让你重新思考身份验证的架构。

🔐 一、WebAuthn 核心原理:为什么比密码安全

WebAuthn(Web Authentication API)是 W3C 标准,基于 FIDO2 协议。它的核心思想很简单:用非对称密钥对代替密码。私钥永远不离开用户设备,服务端只存储公钥。即使数据库被拖库,攻击者拿到的公钥也无法用来伪造身份。

🔑 注册与认证的完整流程

WebAuthn 的两个核心操作是注册(Registration)认证(Authentication)。很多人把这两个流程搞混,导致实现出错。

注册流程:

  1. 用户在客户端发起注册请求
  2. 服务端生成随机 Challenge 并返回
  3. 浏览器调用 navigator.credentials.create(),设备生成密钥对
  4. 私钥存储在设备安全芯片(TPM/Secure Enclave)中
  5. 公钥 + 设备信息签名后返回服务端
  6. 服务端验证签名并存储公钥

认证流程:

  1. 用户发起登录请求
  2. 服务端查询该用户的已注册凭证,生成 Challenge
  3. 浏览器调用 navigator.credentials.get(),用户验证生物识别
  4. 设备用私钥对 Challenge 签名
  5. 服务端用存储的公钥验证签名

📌 **记住:**注册和认证是两个完全不同的 API 调用——create() 用于注册,get() 用于认证。混淆这两个是 WebAuthn 新手最常犯的错误。

🛡️ 与传统认证方式的安全对比

认证方式 钓鱼攻击 中间人攻击 数据库泄露 用户体验 实现复杂度
密码 + 短信验证码 ❌ 易受攻击 ❌ 易受攻击 ⚠️ 哈希可被暴力破解 😞 差
OAuth2 + JWT ✅ 较安全 ⚠️ Token 可被截获 ✅ 无密码存储 🙂 中
TOTP(Google Authenticator) ❌ 可被钓鱼 ⚠️ 一次性码可转发 ✅ 共享密钥风险 😐 中
WebAuthn/Passkeys ✅ 域名绑定 ✅ 签名不可重放 ✅ 仅公钥无用 😊 好

⚡ **关键结论:**WebAuthn 是目前唯一能同时防御钓鱼攻击和中间人攻击的认证方式,因为它将认证域名绑定到了凭证本身——即使用户被诱导到 gooogle.com,浏览器也不会向错误的域名发送凭证。

🚀 二、服务端实现:Node.js + TypeScript 完整代码

理论够了,直接上代码。下面用 @simplewebauthn/server 库实现一个完整的 WebAuthn 服务端。这个库是社区最成熟的 WebAuthn 服务端实现,API 设计清晰。

📦 项目初始化与依赖

# 初始化项目
mkdir webauthn-demo && cd webauthn-demo
npm init -y
npm install @simplewebauthn/server @simplewebauthn/types express
npm install -D typescript @types/express
// src/config.ts - WebAuthn 配置
export const rpName = 'jsjson.com'
export const rpID = 'jsjson.com'  // 生产环境必须使用真实域名
export const origin = `https://${rpID}`

⚠️ 警告:rpID 必须与实际访问的域名一致。开发环境下可以用 localhost,但不能同时使用 127.0.0.1localhost——它们在 WebAuthn 中是不同的 Relying Party ID。

📝 注册接口实现

// src/routes/register.ts - 注册(生成凭证)
import { generateRegistrationOptions } from '@simplewebauthn/server'
import { verifyRegistrationResponse } from '@simplewebauthn/server'
import type { RegistrationResponseJSON } from '@simplewebauthn/types'
import express from 'express'
import { rpName, rpID, origin } from '../config'

const router = express.Router()

// 临时存储 Challenge(生产环境用 Redis)
const challengeStore = new Map<string, string>()

// 第一步:生成注册选项
router.post('/register/options', async (req, res) => {
  const { username } = req.body

  // 查询用户已注册的凭证(防止重复注册)
  const existingCredentials = await getCredentialsByUser(username)

  const options = await generateRegistrationOptions({
    rpName,
    rpID,
    userName: username,
    userDisplayName: username,
    attestationType: 'none',          // 不需要设备证明
    authenticatorSelection: {
      authenticatorAttachment: 'platform',  // 优先使用平台认证器(指纹/面容)
      residentKey: 'preferred',             // 优先创建可发现凭证
      userVerification: 'preferred',        // 优先要求用户验证
    },
    excludeCredentials: existingCredentials.map(cred => ({
      id: cred.credentialID,
      type: 'public-key',
    })),
  })

  // 存储 Challenge 用于后续验证
  challengeStore.set(username, options.challenge)

  res.json(options)
})

// 第二步:验证注册响应
router.post('/register/verify', async (req, res) => {
  const { username, credential } = req.body as {
    username: string
    credential: RegistrationResponseJSON
  }

  const expectedChallenge = challengeStore.get(username)
  if (!expectedChallenge) {
    return res.status(400).json({ error: 'Challenge 不存在或已过期' })
  }

  const verification = await verifyRegistrationResponse({
    response: credential,
    expectedChallenge,
    expectedOrigin: origin,
    expectedRPID: rpID,
  })

  if (!verification.verified || !verification.registrationInfo) {
    return res.status(400).json({ error: '注册验证失败' })
  }

  const { credentialPublicKey, credentialID, counter } = verification.registrationInfo

  // 存储凭证到数据库
  await saveCredential({
    username,
    credentialID: Buffer.from(credentialID).toString('base64url'),
    credentialPublicKey: Buffer.from(credentialPublicKey).toString('base64url'),
    counter,
  })

  // 清除 Challenge
  challengeStore.delete(username)

  res.json({ verified: true })
})

export default router

🔓 认证接口实现

// src/routes/authenticate.ts - 认证(验证身份)
import { generateAuthenticationOptions } from '@simplewebauthn/server'
import { verifyAuthenticationResponse } from '@simplewebauthn/server'
import type { AuthenticationResponseJSON } from '@simplewebauthn/types'
import express from 'express'
import { rpID, origin } from '../config'

const router = express.Router()
const challengeStore = new Map<string, string>()

// 第一步:生成认证选项
router.post('/login/options', async (req, res) => {
  const { username } = req.body
  const credentials = await getCredentialsByUser(username)

  const options = await generateAuthenticationOptions({
    rpID,
    userVerification: 'preferred',
    allowCredentials: credentials.map(cred => ({
      id: cred.credentialID,  // base64url 编码的 credentialID
      type: 'public-key' as const,
    })),
  })

  challengeStore.set(username, options.challenge)
  res.json(options)
})

// 第二步:验证认证响应
router.post('/login/verify', async (req, res) => {
  const { username, credential } = req.body as {
    username: string
    credential: AuthenticationResponseJSON
  }

  const expectedChallenge = challengeStore.get(username)
  if (!expectedChallenge) {
    return res.status(400).json({ error: 'Challenge 不存在或已过期' })
  }

  // 从数据库获取对应的凭证
  const storedCredential = await getCredentialById(username, credential.id)
  if (!storedCredential) {
    return res.status(400).json({ error: '未找到注册凭证' })
  }

  const verification = await verifyAuthenticationResponse({
    response: credential,
    expectedChallenge,
    expectedOrigin: origin,
    expectedRPID: rpID,
    credential: {
      id: storedCredential.credentialID,
      publicKey: Buffer.from(storedCredential.credentialPublicKey, 'base64url'),
      counter: storedCredential.counter,
    },
  })

  if (!verification.verified) {
    return res.status(400).json({ error: '认证失败' })
  }

  // 更新 counter(防止重放攻击)
  await updateCredentialCounter(
    storedCredential.credentialID,
    verification.authenticationInfo.newCounter
  )

  // 生成 JWT 或创建 Session
  const session = await createSession(username)

  challengeStore.delete(username)
  res.json({ verified: true, token: session.token })
})

export default router

💡 提示:counter 机制是 WebAuthn 的重要安全特性。每次认证成功后 counter 递增,服务端必须检查新 counter 大于旧 counter。如果出现 counter 回退,说明可能存在凭证克隆攻击。

💡 三、前端集成与浏览器 API

前端代码比服务端简单得多,核心就是两个 API 调用。

🎨 完整的前端注册与登录

// frontend/webauthn.js - 前端 WebAuthn 集成
// 检测浏览器是否支持 WebAuthn
if (!window.PublicKeyCredential) {
  alert('当前浏览器不支持 WebAuthn')
}

// 注册流程
async function register(username) {
  // 第一步:从服务端获取注册选项
  const optionsResp = await fetch('/api/register/options', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ username }),
  })
  const options = await optionsResp.json()

  // 第二步:调用浏览器 WebAuthn API
  const credential = await navigator.credentials.create({
    publicKey: {
      ...options,
      challenge: base64URLToBuffer(options.challenge),
      user: {
        ...options.user,
        id: base64URLToBuffer(options.user.id),
      },
    },
  })

  // 第三步:将凭证发送到服务端验证
  const verifyResp = await fetch('/api/register/verify', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      username,
      credential: serializeCredential(credential),
    }),
  })

  const result = await verifyResp.json()
  return result.verified
}

// 登录流程
async function login(username) {
  const optionsResp = await fetch('/api/login/options', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ username }),
  })
  const options = await optionsResp.json()

  const credential = await navigator.credentials.get({
    publicKey: {
      ...options,
      challenge: base64URLToBuffer(options.challenge),
      allowCredentials: options.allowCredentials?.map(cred => ({
        ...cred,
        id: base64URLToBuffer(cred.id),
      })),
    },
  })

  const verifyResp = await fetch('/api/login/verify', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      username,
      credential: serializeCredential(credential),
    }),
  })

  const result = await verifyResp.json()
  if (result.verified) {
    localStorage.setItem('token', result.token)
  }
  return result.verified
}

// 工具函数:Base64URL 与 ArrayBuffer 互转
function base64URLToBuffer(base64URL) {
  const padding = '='.repeat((4 - (base64URL.length % 4)) % 4)
  const base64 = base64URL.replace(/-/g, '+').replace(/_/g, '/') + padding
  const binary = atob(base64)
  return Uint8Array.from(binary, c => c.charCodeAt(0)).buffer
}

function bufferToBase64URL(buffer) {
  const bytes = new Uint8Array(buffer)
  let binary = ''
  for (const byte of bytes) binary += String.fromCharCode(byte)
  return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}

// 序列化凭证对象(浏览器返回的是 ArrayBuffer,需要转为 Base64URL)
function serializeCredential(credential) {
  return {
    id: credential.id,
    type: credential.type,
    rawId: bufferToBase64URL(credential.rawId),
    response: {
      clientDataJSON: bufferToBase64URL(credential.response.clientDataJSON),
      attestationObject: credential.response.attestationObject
        ? bufferToBase64URL(credential.response.attestationObject)
        : undefined,
      authenticatorData: credential.response.authenticatorData
        ? bufferToBase64URL(credential.response.authenticatorData)
        : undefined,
      signature: credential.response.signature
        ? bufferToBase64URL(credential.response.signature)
        : undefined,
    },
  }
}

⚠️ **警告:**WebAuthn API 在非 HTTPS 环境下(除 localhost 外)会被浏览器拒绝。开发时请确保使用 localhost 或配置本地 HTTPS 证书,否则 navigator.credentials.create() 会直接抛出 NotAllowedError

🔧 设备绑定策略选择

不同场景需要不同的认证器策略。选错策略会导致用户体验灾难:

策略 authenticatorAttachment residentKey 适用场景
纯平台认证 platform preferred 个人设备、手机 App
纯跨设备认证 cross-platform discouraged 共享电脑、安全密钥
不限类型 不设置 required 通用场景、Passkeys

📌 **记住:**Passkeys 本质上就是 residentKey: 'required' 的平台认证器凭证。它允许用户在不同设备间同步(通过 iCloud Keychain 或 Google Password Manager),是真正的「一次注册,处处登录」。

⚠️ 四、生产环境避坑指南

WebAuthn 的规范很清晰,但生产环境的坑比你想象的多。

🕳️ 常见陷阱

❌ 错误:Challenge 不设过期时间

Challenge 应该有 5 分钟的过期时间。没有过期意味着旧的 Challenge 可以被重放。用 Redis 存储 Challenge 时设置 TTL:

# Redis 存储 Challenge 并设置过期
redis-cli SET "challenge:${username}" "${challenge}" EX 300

❌ 错误:忽略 counter 检查

每次认证成功后,verifyAuthenticationResponse 会返回 newCounter。你必须将其与数据库中存储的旧 counter 对比——如果新 counter 小于或等于旧 counter,说明可能存在凭证克隆。

❌ 错误:rpID 与实际域名不匹配

开发时用 localhost,部署时忘了改成真实域名,导致所有已注册凭证失效。建议通过环境变量管理:

// 通过环境变量管理 rpID
const rpID = process.env.WEBAUTHN_RP_ID || 'localhost'
const origin = process.env.WEBAUTHN_ORIGIN || `http://${rpID}:3000`

❌ 错误:不处理用户已有凭证

注册时没有用 excludeCredentials 排除已注册的设备,导致同一设备重复注册多个凭证,增加管理混乱和安全风险。

✅ 最佳实践清单

  • ✅ Challenge 必须是密码学安全的随机数,长度 ≥ 32 字节
  • ✅ Challenge 必须设置过期时间(推荐 5 分钟)
  • ✅ 注册和认证都必须验证 expectedOriginexpectedRPID
  • ✅ 必须检查 counter 递增
  • ✅ 使用 userVerification: 'preferred' 让设备决定验证方式
  • ✅ 提供「管理已注册设备」功能,允许用户删除丢失的设备凭证
  • ✅ 保留密码作为降级方案,不要一上来就强制无密码

🎯 总结与建议

WebAuthn/Passkeys 不是未来技术,而是当前最安全的用户认证方案。它的安全性不依赖于用户的安全意识(不用记密码、不会被钓鱼),而是依赖于密码学和硬件安全芯片。

落地建议:

  1. 渐进式迁移:先作为第二因素(MFA)上线,用户体验习惯后再升级为首选方式
  2. 保留降级方案:永远保留密码或邮箱链接作为备用登录方式,防止用户丢失设备
  3. 优先支持 PasskeysresidentKey: 'required' + 平台认证器 = 跨设备同步,用户体验最好
  4. 监控认证指标:跟踪注册率、认证成功率、设备类型分布,持续优化
需求 推荐方案 理由
快速集成 SimpleWebAuthn 社区最成熟,API 清晰
全栈框架 NextAuth.js v5 内置 WebAuthn Provider
企业级 Auth0 / Clerk 托管服务,省心省力
自研可控 自己实现 参考本文代码

WebAuthn 的学习曲线确实比密码认证高,但一旦跑通,你会发现它不仅更安全,而且用户反馈也更好——没有人喜欢记密码。

📚 相关文章