编程 告别密码:Passkeys 与 WebAuthn 完全实战指南——从原理到生产部署

2026-07-01 16:22:34 +0800 CST views 8

告别密码:Passkeys 与 WebAuthn 完全实战指南——从原理到生产部署

一、引言:密码的黄昏

2026 年的今天,一个有趣的事实:全球超过 80% 的数据泄露仍然与弱密码或凭证泄露有关。密码这个东西,从上世纪 60 年代 CTSS 系统诞生起,已经陪伴我们超过半个世纪。但你真的信任它吗?

衡量一个密码的好坏,通常看三个维度:强度(够复杂吗)、唯一性(每个网站不同吗)、可记忆(你能记住吗)。这三个维度天然是矛盾的——越安全的密码越难记,能记住的密码往往不安全。这就是所谓的 "密码悖论"

传统解决方案是密码管理器。但这只是把问题从"记住 N 个密码"变成了"记住 1 个主密码"。单点故障依然存在。

就在这个背景下,Passkeys(通行密钥) 在 2022-2026 年间迅速崛起。苹果、谷歌、微软三大平台全线支持,FIDO2/WebAuthn 标准日趋成熟。到 2026 年,Passkeys 的使用量相比 2023 年增长了超过 400%,头部互联网企业基本完成了接入。

这篇文章不讲虚的。我们从最底层开始,把 WebAuthn 和 Passkeys 的每一层技术栈拆开,然后写一个可运行的生产级实现。

二、核心概念:公钥认证 ≠ 密码认证

要理解 Passkeys 为什么安全,先搞清楚它和传统密码的根本区别。

2.1 传统密码认证

用户输入密码 → 服务器验证哈希 → 通过/拒绝

问题在于哈希存储——现代系统会用 bcrypt/argon2 做慢哈希。真正的问题是:

  1. 服务端必须持有验证秘密:服务器要么存了你的密码(哈希),要么发了临时验证码。如果服务器被拖库,攻击者可以离线爆破。
  2. 无法抵御钓鱼:你无法区分登录了真网站还是假网站。
  3. 无法实现"零知识证明":你总是需要把秘密告诉对方,然后期待对方妥善保管。

2.2 公钥认证

WebAuthn 采用非对称加密:

用户设备生成密钥对 → 公钥交给服务器 → 登录时用私钥签名挑战 → 服务器用公钥验签

关键区别:

特性密码Passkeys
秘密存放在用户大脑 → 服务器数据库用户设备(安全芯片)
服务器泄露后果密码泄露,可离线破解只有公钥,攻击者无法伪造签名
钓鱼防御原生(签名绑定域名)
跨设备同步不适用平台密钥库(iCloud Keychain/Google Password Manager)

2.3 协议栈全景

从硬件到浏览器到服务器,WebAuthn 的协议栈是这样的:

┌─────────────────────────────────────┐
│          你的 Web 应用                │
├─────────────────────────────────────┤
│    WebAuthn API (navigator.credentials) │
├─────────────────────────────────────┤
│     CTAP2 (Client to Authenticator)   │
├─────────────────────────────────────┤
│   FIDO2 Authenticator (平台 / 外置)   │
├─────────────────────────────────────┤
│         安全硬件 (SE / TEE / TPM)      │
└─────────────────────────────────────┘
  • WebAuthn(W3C 标准):浏览器端的 JavaScript API
  • CTAP2(FIDO Alliance):浏览器与认证器之间的通信协议
  • FIDO2 认证器:可以是内置于设备的(如 Mac Touch ID、Windows Hello),也可以是外置的 USB/NFC/蓝牙安全密钥

这里要重点理解一个概念:WebAuthn ≠ Passkeys。WebAuthn 是底层协议,Passkeys 是 WebAuthn 的一个"产品化方案"。具体来说,Passkeys 允许用户将 WebAuthn 凭证安全地同步到所有设备上(通过 iCloud、Google 账户等),从而解决"一个设备一个密钥"的使用障碍。

三、认证器类型与架构

3.1 平台认证器 vs 跨平台认证器

平台认证器(Platform Authenticator)

内置在设备中,不可插拔移除:

  • MacBook 的 Touch ID + Secure Enclave
  • Windows 的 Windows Hello + TPM
  • 安卓设备的 TEE(可信执行环境)
  • iPhone 的 Face ID + Secure Enclave

跨平台认证器(Cross-Platform Authenticator)

可移除、可插拔:

  • USB-C / NFC 安全密钥(YubiKey、Google Titan)
  • 手机作为蓝牙认证器

3.2 可验证凭证(Resident Key)与非可验证凭证

这个概念很多人搞混。

  • Non-resident key(非驻留密钥):私钥不完全存储在认证器上。认证器通过一个"句柄"(credential ID)来标识凭证,真正密钥可以基于这个句柄和设备密钥派生。FIDO U2F 时代基本都是这种。
  • **Resident key(驻留密钥)—— 也就是 Discoverable Credential:私钥完全存储在认证器上。认证器可以"发现"所有凭证。Passkeys 必须是 Resident Key。

为什么这对 Passkeys 重要?因为当你需要在没有预先"登录"情况下的认证时(比如在另一台设备上登录),认证器需要能主动列出可用的凭证。这就是 Resident Key 的用武之地。

3.3 Passkeys 的同步架构

Passkeys 最巧妙的设计在于它的同步方式:

                    Apple 生态
    ┌───────────────┼───────────────┐
    │               │               │
  iPhone A      iPhone B       MacBook
 (Secure         (iCloud        (iCloud
  Enclave)        Keychain)      Keychain)
                    │
                    │ 端到端加密同步
                    ▼
                iCloud 服务器
            (Apple 也无法解密)

每个设备上的 Secure Enclave 生成自己的设备密钥,通过 Apple 的端到端加密协议同步到其他设备。这意味着:

  1. 你在 Mac 上注册了一个网站的 Passkey,iPhone 上自动可用
  2. Apple(服务器端)无法看到你的私钥内容
  3. 丢失设备时,只要 iCloud 账户恢复,Passkeys 就能恢复

Google 和微软的方案与此类似,但各自的同步基础设施不同。

四、WebAuthn API 深度解析

4.1 注册流程(Attestation)

注册过程的本质是:用户设备生成一对公私钥,将公钥连同证明(attestation)一并交给服务器。

先看序列图:

浏览器                  认证器                  服务器
  │                       │                       │
  │── navigator.credentials.create()               │
  │── PublicKeyCredentialCreationOptions ──→       │
  │                       │── 用户交互 (生物识别)   │
  │                       │── 生成公私钥对         │
  │                       │── 返回 attestation     │
  │←── PublicKeyCredential ──                      │
  │── POST /register (attObj, clientDataJSON) ──→  │
  │                                               │── 验证签名
  │                                               │── 存储公钥
  │←── 200 OK ──────────────────────────────────── │

下面是完整的客户端 JavaScript 实现:

async function register(username) {
  // 1. 服务器先创建注册选项
  const optionsResponse = await fetch('/api/webauthn/register/begin', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ username }),
  });
  const options = await optionsResponse.json();

  // 2. 浏览器将 options 转换为 API 需要的格式
  //    challenge, user.id 等是 Base64URL 编码的,需要转回 ArrayBuffer
  const publicKey = {
    challenge: base64urlToArrayBuffer(options.challenge),
    rp: options.rp,
    user: {
      id: base64urlToArrayBuffer(options.user.id),
      name: options.user.name,
      displayName: options.user.displayName,
    },
    pubKeyCredParams: options.pubKeyCredParams,
    authenticatorSelection: {
      // residentKey: 'required' → 创建 Passkey
      residentKey: 'required',
      userVerification: 'preferred',
    },
    // 超时 5 分钟
    timeout: 300000,
    attestation: 'none',  // 不用设备证明,简化流程
  };

  // 3. 调用浏览器 API 创建凭证
  const credential = await navigator.credentials.create({
    publicKey,
  });

  // 4. 将凭证对象序列化后发给服务器
  const credentialData = {
    id: credential.id,
    rawId: arrayBufferToBase64url(credential.rawId),
    type: credential.type,
    response: {
      clientDataJSON: arrayBufferToBase64url(
        credential.response.clientDataJSON
      ),
      attestationObject: arrayBufferToBase64url(
        credential.response.attestationObject
      ),
    },
    // 如果支持自动填充,返回客户端扩展信息
    clientExtensionResults: credential.getClientExtensionResults(),
  };

  // 5. 发送注册完成请求
  const result = await fetch('/api/webauthn/register/complete', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(credentialData),
  });

  return result.json();
}

// 工具函数:Base64URL ↔ ArrayBuffer
function base64urlToArrayBuffer(base64url) {
  const padding = '='.repeat((4 - (base64url.length % 4)) % 4);
  const base64 = (base64url + padding)
    .replace(/-/g, '+')
    .replace(/_/g, '/');
  const raw = atob(base64);
  return Uint8Array.from(raw, c => c.charCodeAt(0)).buffer;
}

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

4.2 关键参数解读

pubKeyCredParams 指定支持的算法:

[
  { "type": "public-key", "alg": -7 },   // ES256 (ECDSA w/ SHA-256)
  { "type": "public-key", "alg": -257 }  // RS256 (RSA w/ SHA-256)
]

算法编号是 COSE Algorithm Identifier

  • -7 = ES256(ECDSA P-256 + SHA-256),这是最广泛支持的算法
  • -8 = EdDSA(Ed25519),性能更好,2024年后浏览器逐渐支持
  • -257 = RS256(RSA 2048 + SHA-256)

顺序很重要:排在前面的优先使用。建议把 ES256 放在最前,因为绝大多数设备原生支持 P-256。

authenticatorSelection 控制认证器行为:

authenticatorSelection: {
  // 'required' | 'preferred' | 'discouraged'
  residentKey: 'required',
  
  // 'required' | 'preferred' | 'discouraged'
  userVerification: 'preferred',
  
  // 'platform' | 'cross-platform' | undefined
  authenticatorAttachment: 'platform',
}
  • residentKey: 'required' → 创建的是 Passkey,而非 legacy credential
  • userVerification: 'preferred' → 最好有生物识别,但没有也没关系
  • authenticatorAttachment: 'platform' → 限定使用内置认证器(Touch ID 等),不加的话会弹出一个对话框让用户选

4.3 认证流程(Assertion)

认证过程就是"用私钥签名一个挑战":

async function authenticate() {
  // 1. 获取认证选项
  const optionsResponse = await fetch('/api/webauthn/auth/begin', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
  });
  const options = await optionsResponse.json();

  const publicKey = {
    challenge: base64urlToArrayBuffer(options.challenge),
    timeout: 300000,
    userVerification: 'preferred',
    // 不指定 allowCredentials → 让用户选择哪个账号登录
    // 指定则只接受特定凭证
    ...(options.allowCredentials?.length > 0 && {
      allowCredentials: options.allowCredentials.map(cred => ({
        id: base64urlToArrayBuffer(cred.id),
        type: cred.type,
        transports: cred.transports,
      })),
    }),
  };

  const credential = await navigator.credentials.get({
    publicKey,
    // mediation: 'conditional' → 启用自动填充
    mediation: 'conditional',
  });

  const credentialData = {
    id: credential.id,
    rawId: arrayBufferToBase64url(credential.rawId),
    type: credential.type,
    response: {
      authenticatorData: arrayBufferToBase64url(
        credential.response.authenticatorData
      ),
      clientDataJSON: arrayBufferToBase64url(
        credential.response.clientDataJSON
      ),
      signature: arrayBufferToBase64url(
        credential.response.signature
      ),
      userHandle: credential.response.userHandle
        ? arrayBufferToBase64url(credential.response.userHandle)
        : null,
    },
  };

  const result = await fetch('/api/webauthn/auth/complete', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(credentialData),
  });

  return result.json();
}

4.4 Conditional Mediation——自动填充体验

这是 Passkeys 用户体验最关键的部分。navigator.credentials.get()mediation: 'conditional' 参数实现了免点击登录

// 页面加载时立即请求,不会触发弹窗
// 浏览器会在密码输入框旁边提示"使用通行密钥登录"
const credential = await navigator.credentials.get({
  publicKey: { ... },
  mediation: 'conditional',
});

配合 CSS 的 autocomplete="username-webauthn",浏览器会在登录表单上显示一个自动填充提示:

<input 
  type="text" 
  name="username" 
  autocomplete="username webauthn" 
  placeholder="用户名或邮箱"
/>

用户在输入框获得焦点时,系统键盘会弹出 Passkey 建议。点击后自动完成认证,整个过程不需要手动输入任何东西。

五、服务端实现(Go 语言)

服务端的核心工作是三件事:

  1. 生成注册/认证挑战
  2. 验证客户端返回的凭证
  3. 管理用户与公钥的映射

我们用 Go 写一个完整的实现,使用 github.com/go-webauthn/webauthn 库。

5.1 数据模型

package webauthn

import (
	"time"
	"github.com/go-webauthn/webauthn/webauthn"
)

// User 实现 webauthn.User 接口
type User struct {
	ID          []byte    `json:"id"`
	Name        string    `json:"name"`
	DisplayName string    `json:"displayName"`
	Credentials []webauthn.Credential `json:"credentials"`
	CreatedAt   time.Time `json:"createdAt"`
}

// 实现 webauthn.User 接口
func (u *User) WebAuthnID() []byte                     { return u.ID }
func (u *User) WebAuthnName() string                   { return u.Name }
func (u *User) WebAuthnDisplayName() string             { return u.DisplayName }
func (u *User) WebAuthnCredentials() []webauthn.Credential { return u.Credentials }
func (u *User) WebAuthnIcon() string                    { return "" }

// 添加新凭证
func (u *User) AddCredential(cred *webauthn.Credential) {
	u.Credentials = append(u.Credentials, *cred)
}

5.2 服务初始化

package main

import (
	"encoding/json"
	"log"
	"net/http"
	"github.com/go-webauthn/webauthn/webauthn"
)

var (
	// WebAuthn 实例
	webAuthn *webauthn.WebAuthn
	
	// 简化版内存存储(生产环境请用数据库)
	users    = NewUserStore()
	sessions = NewSessionStore()
)

func initWebAuthn() {
	var err error
	webAuthn, err = webauthn.New(&webauthn.Config{
		RPDisplayName: "我的应用",             // 显示名称
		RPID:          "example.com",           // 依赖方 ID,必须是域名
		RPOrigin:      "https://example.com",   // 允许的来源
		RPOrigins:     []string{"https://example.com"},
		
		// 认证超时
		Timeout: 300000, // 5分钟
		
		// 支持的算法,按优先级排列
		CredentialAlgorithms: []int{
			webauthn.COSEAlgorithmIdentifierES256,  // -7, ECDSA P-256
			webauthn.COSEAlgorithmIdentifierEdDSA,  // -8, Ed25519
			webauthn.COSEAlgorithmIdentifierRS256,  // -257, RSA 2048
		},
		
		// Attestation 偏好
		AttestationPreference: webauthn.PreferNoAttestation,
	})
	if err != nil {
		log.Fatal("failed to create webauthn:", err)
	}
}

5.3 注册端点

注册开始

func BeginRegistration(w http.ResponseWriter, r *http.Request) {
	var req struct {
		Username string `json:"username"`
	}
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		http.Error(w, "invalid request", http.StatusBadRequest)
		return
	}

	// 创建用户(或从数据库加载现有用户)
	user := &User{
		ID:          []byte(uuid.New().String()),
		Name:        req.Username,
		DisplayName: req.Username,
		CreatedAt:   time.Now(),
	}
	users.Store(user.ID, user)

	// 生成注册选项
	opts := []webauthn.RegistrationOption{
		webauthn.WithAuthenticatorSelection(webauthn.AuthenticatorSelection{
			// 创建 Passkey
			ResidentKey:      webauthn.ResidentKeyRequirementRequired,
			UserVerification: webauthn.UserVerificationRequirementPreferred,
		}),
		// 兼容外置安全密钥
		webauthn.WithExclusions(user.WebAuthnCredentials()),
	}

	options, sessionData, err := webAuthn.BeginRegistration(user, opts...)
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}

	// 保存 sessionData 以备验证阶段使用
	sessions.Store(sessionData.Challenge, sessionData)

	// 返回给前端
	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(options)
}

注册完成

func FinishRegistration(w http.ResponseWriter, r *http.Request) {
	var credentialData webauthn.CredentialCreationResponse
	if err := json.NewDecoder(r.Body).Decode(&credentialData); err != nil {
		http.Error(w, "invalid credential", http.StatusBadRequest)
		return
	}

	// 从 session 中获取用户和挑战
	sessionData := sessions.Load(credentialData.Response.ClientDataJSON.Challenge)
	user := users.Load(sessionData.UserID)

	// 验证凭证
	credential, err := webAuthn.FinishRegistration(user, *sessionData, &credentialData)
	if err != nil {
		http.Error(w, "verification failed: "+err.Error(), http.StatusUnauthorized)
		return
	}

	// 保存凭证
	user.AddCredential(credential)
	users.Store(user.ID, user)

	w.WriteHeader(http.StatusCreated)
	json.NewEncoder(w).Encode(map[string]interface{}{
		"status": "ok",
		"credentialId": credential.ID,
	})
}

5.4 认证端点

认证开始

func BeginAuthentication(w http.ResponseWriter, r *http.Request) {
	var req struct {
		Username string `json:"username,omitempty"` // 可选,提前指定用户
	}
	json.NewDecoder(r.Body).Decode(&req)

	var user *User
	var opts []webauthn.LoginOption

	if req.Username != "" {
		// 如果用户指定了用户名,只允许该用户的凭证
		user = users.FindByName(req.Username)
		if user == nil {
			http.Error(w, "user not found", http.StatusNotFound)
			return
		}
		opts = append(opts, webauthn.WithAllowedCredentials(user.WebAuthnCredentials()))
	}

	// 如果没有指定用户名,使用空选项 → 让客户端选择凭证
	// 适用于 Conditional Mediation(自动填充)
	options, sessionData, err := webAuthn.BeginLogin(user, opts...)
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}

	sessions.Store(sessionData.Challenge, sessionData)

	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(options)
}

认证完成

func FinishAuthentication(w http.ResponseWriter, r *http.Request) {
	var credentialData webauthn.CredentialAssertionResponse
	if err := json.NewDecoder(r.Body).Decode(&credentialData); err != nil {
		http.Error(w, "invalid credential", http.StatusBadRequest)
		return
	}

	sessionData := sessions.Load(credentialData.Response.ClientDataJSON.Challenge)
	user := users.Load(sessionData.UserID)

	// 验证签名
	credential, err := webAuthn.FinishLogin(user, *sessionData, &credentialData)
	if err != nil {
		http.Error(w, "authentication failed: "+err.Error(), http.StatusUnauthorized)
		return
	}

	// 更新凭证计数器(防止凭证克隆攻击)
	user.UpdateCredentialCounter(credential)
	users.Store(user.ID, user)

	// 生成会话 Token
	sessionToken := generateSessionToken(user.ID)
	
	json.NewEncoder(w).Encode(map[string]interface{}{
		"status": "ok",
		"token":  sessionToken,
	})
}

5.5 签名验证——底层原理

不要被上面的库调用骗了,觉得 WebAuthn 验证很"简单"。真正的工作在 Finishing 函数内部。服务端验证签名时,需要做以下检查:

  1. 解析 authenticatorData

    • rpIdHash(前 32 字节):SHA-256 哈希后的 RP ID,必须和期望值一致
    • flags(1 字节):包含 UP(用户存在)、UV(用户验证)、AT(附带凭证数据)等标志位
    • signCount(4 字节):凭证计数器,递增或允许 0(不支持计数器的认证器)
  2. 解析 clientDataJSON

    • type:必须是 "webauthn.create"(注册)或 "webauthn.get"(认证)
    • challenge:必须等于服务端之前缓存的挑战
    • origin:必须等于允许的来源列表中的某个值
    • crossOrigin(可选):跨源请求时是 true,必须根据场景判断
  3. 拼接签名数据并验签

    签名数据 = authenticatorData + SHA-256(clientDataJSON)
    

    用存储的公钥对签名数据 + signature 做验签。

这个验证过程绝对不能省略任何一个步骤。尤其是 origin 校验,它是防御钓鱼攻击的关键——如果 origin 校验缺失,攻击者可以制作一个看似一模一样的假页面来诱导用户认证。

六、安全性深度分析

6.1 Passkeys 防什么?

攻击类型Passkeys 如何防御
钓鱼攻击签名包含 origin,假网站无法拿到有效签名
密码泄露(服务器端)服务器只有公钥,泄露后无法伪造登录
密码重放Challenge 一次性,无法重放
凭证克隆Counter 自增,重放攻击会被检测(但仅对有限且支持 counter 的认证器有效)
中间人攻击TLS + origin 绑定
撞库攻击Passkeys 与具体域名绑定,无法跨站重用

6.2 Passkeys 不防什么?

设备丢失:如果有恶意者拿到了你的已解锁设备(或在短时间内猜出你的锁屏密码/生物识别),他们可以用你的 Passkeys 登录。但是:

  • 大多数平台支持远程吊销(如 Find My iPhone → 擦除设备 → 同步删除 Passkeys)
  • 平台密钥库有额外的安全保护(如 Apple 的 Secure Enclave)

法庭强制:在某些司法管辖区,法庭可以强迫你解锁设备(生物识别在某些国家被认为具有"见证人"属性)。但理论上你无法被强制"交出"一个你不知道的密钥。

6.3 计数器(Counter)的意义与局限

计数器是认证器上的一个 32 位无符号整数,每次使用凭证时递增。它本质是防凭证克隆的。

如果攻击者复制了你的认证器(物理克隆),原设备和克隆设备各自使用后,计数会"分叉"。服务器看到使用过的更低计数器时,应标记该凭证可能被克隆。

但是,这个机制有严重局限:

  • 多数平台认证器(Windows Hello、Touch ID)出于性能考虑,不支持计数器(始终为 0)
  • 只有 YubiKey 等外置安全密钥才正确实现计数器递增
  • 即使计数器不增,也不能认定为攻击——可能是旧设备、缓存副本等

所以实践中,如果你需要使用计数器作为安全指标,应该在注册时检查认证器能力:

// 检查认证器是否支持计数器
hasCounter := authenticatorData.SignCount > 0

if hasCounter && credential.Authenticator.SignCount < storedCounter {
    // 可能的凭证克隆!标记待审查
    flagForReview(credential.ID)
}

七、条件中介(Conditional Mediation)与用户体验

7.1 实现免点击登录

这是 2024-2026 年间 Passkeys 用户体验的最大改进。早期 Passkeys 需要用户主动点击一个"使用通行密钥登录"的按钮,而现在通过 Conditional Mediation,登录流程变成:

1. 用户到达登录页面
2. 焦点落在 username 输入框
3. 系统自动弹出 Passkey 建议
4. 用户通过生物识别确认
5. 瞬间完成登录

7.2 完整前端实现

<!DOCTYPE html>
<html>
<head>
  <title>Passkey 登录</title>
  <style>
    .passkey-indicator {
      display: none;
      align-items: center;
      gap: 8px;
      padding: 8px 12px;
      background: #f0fdf4;
      border: 1px solid #86efac;
      border-radius: 8px;
      color: #166534;
      font-size: 14px;
      margin-bottom: 16px;
    }
    .passkey-indicator.visible {
      display: flex;
    }
  </style>
</head>
<body>
  <div class="passkey-indicator" id="passkeyIndicator">
    🔑 可使用通行密钥自动登录
  </div>

  <form id="loginForm">
    <input 
      type="text" 
      id="username" 
      autocomplete="username webauthn" 
      placeholder="用户名或邮箱"
      required
    />
    <input 
      type="password" 
      id="password" 
      autocomplete="current-password" 
      placeholder="密码" 
    />
    <button type="submit">登录</button>
  </form>

  <script>
    // 页面加载时立即尝试自动填充 Passkey
    async function tryConditionalMediation() {
      if (!window.PublicKeyCredential ||
          !PublicKeyCredential.isConditionalMediationAvailable) {
        return;
      }

      const isCMA = await PublicKeyCredential
        .isConditionalMediationAvailable();
      if (!isCMA) return;

      // 显示 Passkey 提示
      document.getElementById('passkeyIndicator')
        .classList.add('visible');

      try {
        // 获取认证选项
        const resp = await fetch('/api/webauthn/auth/begin');
        const options = await resp.json();

        // 使用 Conditional Mediation
        const credential = await navigator.credentials.get({
          publicKey: {
            challenge: base64urlToArrayBuffer(options.challenge),
            timeout: 300000,
            userVerification: 'preferred',
          },
          mediation: 'conditional',
        });

        // ... 提交凭证到服务器
        const result = await submitCredential(credential);
        if (result.status === 'ok') {
          window.location.href = '/dashboard';
        }
      } catch (err) {
        // 用户取消或没有可用凭证,正常回退到密码登录
        console.log('Passkey 不可用:', err.name);
        document.getElementById('passkeyIndicator')
          .classList.remove('visible');
      }
    }

    tryConditionalMediation();
  </script>
</body>
</html>

关键细节:navigator.credentials.get()mediation: 'conditional' 调用不会立即显示任何 UI。浏览器默默地等待用户在自动填充建议中选择一个 Passkey。如果用户不选择,函数会保持 pending 状态,直到显式取消(AbortController)。

所以最佳实践是用一个 AbortController 配合 setTimeout

const controller = new AbortController();
setTimeout(() => controller.abort(), 60000); // 1 分钟超时

const credential = await navigator.credentials.get({
  publicKey: { ... },
  mediation: 'conditional',
  signal: controller.signal,
});

八、Passkeys 迁移:密码 → 无密码

8.1 渐进式迁移策略

不可能一夜间干掉密码。以下是推荐的分阶段迁移策略:

第一阶段:共存

现有密码登录 ←→ 新增 Passkey 注册

用户仍然可以用密码登录,但可以在安全设置中添加 Passkey。这个阶段持续 3-6 个月。

第二阶段:降权

优先 Passkey 登录,密码作为备用

登录页面默认建议使用 Passkey,密码入口改为"使用其他方式登录",折叠在一个 <details> 标签里。

第三阶段:可选关闭

仅 Passkey,对已绑定的用户

对已经绑定了 Passkey 的用户,禁用密码登录。未绑定的用户仍然可以用密码。

8.2 后端迁移的关键:合并账号

当用户先注册了传统密码账号,后来又添加 Passkey 时,需要确保:

// 添加 Passkey 到已有账户
func AttachPasskeyToExistingAccount(w http.ResponseWriter, r *http.Request) {
	userID := getCurrentUser(r) // 从现有会话中获取

	user := users.Load(userID)
	if user == nil {
		http.Error(w, "not logged in", http.StatusUnauthorized)
		return
	}

	options, sessionData, err := webAuthn.BeginRegistration(user)
	// ... 与注册流程相同,只是针对已登录用户
}

这一点很多团队会忘记:Passkey 注册必须在已登录状态下发起,否则会创建一个"孤立账号"。好的做法是:

  1. 用户在登录后才能添加 Passkey
  2. 或者,在注册完 Passkey 后,提示用户"是否关联到已有账号"
  3. 在 Passkey 的 user.id 字段中嵌入关联标识(推荐使用 UUID,不推荐用邮箱)

8.3 跨设备同步的挑战

Passkeys 的同步机制是"平台锁定"的:

平台同步方式问题
AppleiCloud Keychain仅限 Apple 设备
GoogleGoogle Password Manager仅限 Chrome/Android
MicrosoftMicrosoft Authenticator仅限 Edge/Windows
第三方1Password, Bitwarden跨平台,但非原生

这意味着一个用户在 iPhone 上注册了 Passkey,在 Windows 上用 Chrome 访问同一个网站时,Passkey 不会自动出现。解决方案:

方案 A:二维码扫码(Hybrid Transport)

navigator.credentials.get() 支持通过 QR 码让手机认证器为桌面设备登录。这是 CTAP2.2 的特性:

const credential = await navigator.credentials.get({
  publicKey: { ... },
  // 允许使用手机进行跨设备认证
});

浏览器在桌面端会显示一个 QR 码,手机扫描后用其本地 Passkey 签名,响应通过蓝牙/NFC 传回桌面端。

方案 B:第三方密码管理器

1Password、Bitwarden 等服务已经支持跨平台 Passkey 同步。他们通过浏览器扩展实现了对所有平台的覆盖。

方案 C:混合存储

考虑同时存储多个凭证(每个平台一个),用户注册时提示"你想要在这个设备上使用 Passkey 吗?"。

九、生产环境部署清单

当你准备在生产环境中上线 Passkeys 时,以下事项不可忽略:

9.1 RP ID 的选择

RP ID 是 WebAuthn 的安全根基——它决定了凭证绑定的"域"。一旦用户在一个 RP ID 下注册了凭证,这个凭证就只能被属于这个 RP ID 的网站使用。

// ❌ 错误:不要用子域名作为 RP ID
webAuthn, _ = webauthn.New(&webauthn.Config{
    RPID: "auth.example.com",  // 这样只有 auth.example.com 能用
})

// ✅ 正确:使用顶级域名
webAuthn, _ = webauthn.New(&webauthn.Config{
    RPID: "example.com",  // *.example.com 都能用
})

但注意:RPOrigin 必须是与页面 URL 完全匹配的来源!

// 安全配置
RPOrigin:  "https://app.example.com",
RPOrigins: []string{
    "https://app.example.com",
    "https://api.example.com",
},
RPID:      "example.com",

9.2 Challenge 管理

Challenge 必须是不可预测的、一次性使用的。推荐方案:

func generateChallenge() []byte {
    buf := make([]byte, 32) // 256 位随机数
    rand.Read(buf)
    return buf
}

存储方式(生产环境应该用 Redis):

type SessionDataStore struct {
    // Key: challenge 的 SHA-256 哈希
    // Value: sessionData
    // TTL: 5 分钟
    store map[string]SessionEntry
}

type SessionEntry struct {
    Data      webauthn.SessionData
    ExpiresAt time.Time
}

func (s *SessionStore) Store(data webauthn.SessionData) {
    key := sha256Hash(data.Challenge)
    // 5 分钟后过期
    s.store[key] = SessionEntry{
        Data:      data,
        ExpiresAt: time.Now().Add(5 * time.Minute),
    }
}

func (s *SessionStore) Load(challenge []byte) *webauthn.SessionData {
    key := sha256Hash(challenge)
    entry, ok := s.store[key]
    if !ok || time.Now().After(entry.ExpiresAt) {
        delete(s.store, key)
        return nil
    }
    delete(s.store, key) // 一次性使用
    return &entry.Data
}

9.3 TLS 要求

WebAuthn 强制要求 HTTPS,即使是在 localhost 上(除了 localhost 本身在规范中有豁免)。所以:

  • 开发环境:localhost 工作正常
  • 预发布环境:必须配置有效 TLS 证书
  • 生产环境:HTTPS + HSTS

9.4 浏览器兼容性

截至 2026 年 Q2,WebAuthn 的浏览器支持情况:

浏览器平台认证器跨平台认证器Conditional Mediation
Chrome
Safari
Firefox⚠️ 2025 年才支持
Edge
Safari iOS
Chrome Android

9.5 降级与回退

当浏览器不支持 WebAuthn 时,必须优雅降级:

if (!window.PublicKeyCredential) {
  // 浏览器不支持 WebAuthn
  // 回退到传统密码登录
  showPasswordForm();
  return;
}

// 下个版本可能支持,但浏览器未必更新
if (!(await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable())) {
  // 没有可用的平台认证器,展示外置安全密钥或密码
  showAlternativeOptions();
}

十、常见陷阱与避坑指南

10.1 陷阱:user.id 用邮箱

// ❌ 用户 ID 用邮箱
user := &User{
    ID: []byte("alice@example.com"),
    // ...
}

// ✅ 使用随机 UUID,邮箱作为展示名
user := &User{
    ID:          []byte(uuid.New().String()),
    Name:        "alice@example.com",
    DisplayName: "Alice",
}

为什么不能用邮箱?因为 user.id 在注册时就嵌入了凭证中。如果用户更改邮箱,凭证就和旧 ID 绑定,无法无缝切换。

10.2 陷阱:忽略 token 轮换

// ❌ credential.id 直接作为凭证标识存储
// 攻击者获取了 credential.id 无法直接利用,
// 但可以用来跟踪用户
storeCredential(rawCredential.id);

// ✅ credential.id 仅作为外键,内部使用自增 ID
const dbID = storeCredential(rawCredential.id);

10.3 陷阱:验证时放松 origin 检查

// ❌ 危险的"灵活"检查
func verifyOrigin(clientDataJSON) bool {
    origins := []string{
        "https://example.com",
        "https://preview.example.com",  // 预发布环境
    }
    // 如果攻击者注册了 preview.example.com 的恶意版本... 
    for _, o := range origins {
        if clientDataJSON.Origin == o {
            return true
        }
    }
    return false
}

// ✅ 严格匹配
func verifyOrigin(clientDataJSON) bool {
    allowedOrigins := map[string]bool{
        "https://example.com": true,
    }
    return allowedOrigins[clientDataJSON.Origin]
}

10.4 陷阱:Session Data 不设 TTL

Challenge 必须是短时效的。5 分钟是合理上限。如果不设 TTL,攻击者可以用一个旧的 Challenge 发起认证请求。

10.5 陷阱:用户确认交互

WebAuthn 规范要求认证器在签名前必须确认"用户存在"(UP flag),但不要求"用户验证"(UV flag)。这意味着:

  • userVerification: 'discouraged' → 可能只需要点一下按钮,不需要生物识别
  • userVerification: 'required' → 必须生物识别或 PIN

如果你的安全策略要求强用户验证,在服务端检查 UV flag:

func checkUserVerification(authData webauthn.AuthenticatorData) bool {
    return authData.Flags.HasUserVerified()
}

十一、展望:2026 年后的无密码世界

Passkeys 正在从根本上改变 Web 认证的格局。以下是我认为未来 2-3 年的趋势:

  1. 密码管理器的消亡:当操作系统级 Passkey 同步变得流畅后,第三方密码管理器逐步退化为"企业管理员"和"跨平台桥梁"。

  2. 多因子融合:Passkeys 本身就是"双因素"——你拥有的(设备)和你就是的(生物识别)。这叫做"内置多因子"。不再需要分开管理密码 + 验证码。

  3. Passkey 授权(不只是认证):未来的 WebAuthn 扩展将支持用 Passkey 做交易签名、授权 API 调用、签署文档等——不限于登录。

  4. 恢复机制标准化:当前最大的痛点——设备丢失后恢复 Passkeys——正在被各平台解决。Apple 的 Recovery Contact、Google 的 Account Recovery 都在做这件事。期待一个跨平台的恢复协议。

  5. 服务端 WebAuthn:不仅浏览器能调用,服务端也能通过安全芯片调用 TPM/SE 来签名。这意味着机器身份、CI/CD 管道身份都可以用 Passkey 保护。

十二、总结

WebAuthn / Passkeys 不是"银弹",它不能解决所有安全问题。它解决的是认证凭证泄露这个最广泛的安全威胁。而且它做得很好——基于非对称加密的设计从根本上避免了密码时代的大部分攻击向量。

从实现角度看,WebAuthn 协议的复杂性是最大的门槛。但好在成熟的库(go-webauthn、simplewebauthn、py_webauthn)已经将这个复杂性封装起来。真正需要你自己处理的是:用户体验设计(Conditional Mediation 的落地)、迁移策略(传统密码到 Passkeys 的平滑过渡)、以及恢复机制(用户丢失设备后的补救)。

如果你的网站还没有接入 Passkeys,现在就是最好的时机。浏览器支持已经成熟,用户期望正在形成,技术栈也已经就绪。

参考资源:

复制全文 生成海报 WebAuthn Passkeys FIDO2 安全 认证 Go 前端 无密码

推荐文章

Vue中的异步更新是如何实现的?
2024-11-18 19:24:29 +0800 CST
js迭代器
2024-11-19 07:49:47 +0800 CST
在Rust项目中使用SQLite数据库
2024-11-19 08:48:00 +0800 CST
Java环境中使用Elasticsearch
2024-11-18 22:46:32 +0800 CST
Vue 3 中的 Watch 实现及最佳实践
2024-11-18 22:18:40 +0800 CST
PHP设计模式:单例模式
2024-11-18 18:31:43 +0800 CST
程序员茄子在线接单