告别密码:Passkeys 与 WebAuthn 完全实战指南——从原理到生产部署
一、引言:密码的黄昏
2026 年的今天,一个有趣的事实:全球超过 80% 的数据泄露仍然与弱密码或凭证泄露有关。密码这个东西,从上世纪 60 年代 CTSS 系统诞生起,已经陪伴我们超过半个世纪。但你真的信任它吗?
衡量一个密码的好坏,通常看三个维度:强度(够复杂吗)、唯一性(每个网站不同吗)、可记忆(你能记住吗)。这三个维度天然是矛盾的——越安全的密码越难记,能记住的密码往往不安全。这就是所谓的 "密码悖论"。
传统解决方案是密码管理器。但这只是把问题从"记住 N 个密码"变成了"记住 1 个主密码"。单点故障依然存在。
就在这个背景下,Passkeys(通行密钥) 在 2022-2026 年间迅速崛起。苹果、谷歌、微软三大平台全线支持,FIDO2/WebAuthn 标准日趋成熟。到 2026 年,Passkeys 的使用量相比 2023 年增长了超过 400%,头部互联网企业基本完成了接入。
这篇文章不讲虚的。我们从最底层开始,把 WebAuthn 和 Passkeys 的每一层技术栈拆开,然后写一个可运行的生产级实现。
二、核心概念:公钥认证 ≠ 密码认证
要理解 Passkeys 为什么安全,先搞清楚它和传统密码的根本区别。
2.1 传统密码认证
用户输入密码 → 服务器验证哈希 → 通过/拒绝
问题不在于哈希存储——现代系统会用 bcrypt/argon2 做慢哈希。真正的问题是:
- 服务端必须持有验证秘密:服务器要么存了你的密码(哈希),要么发了临时验证码。如果服务器被拖库,攻击者可以离线爆破。
- 无法抵御钓鱼:你无法区分登录了真网站还是假网站。
- 无法实现"零知识证明":你总是需要把秘密告诉对方,然后期待对方妥善保管。
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 的端到端加密协议同步到其他设备。这意味着:
- 你在 Mac 上注册了一个网站的 Passkey,iPhone 上自动可用
- Apple(服务器端)无法看到你的私钥内容
- 丢失设备时,只要 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 credentialuserVerification: '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 语言)
服务端的核心工作是三件事:
- 生成注册/认证挑战
- 验证客户端返回的凭证
- 管理用户与公钥的映射
我们用 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 函数内部。服务端验证签名时,需要做以下检查:
解析
authenticatorData:- rpIdHash(前 32 字节):SHA-256 哈希后的 RP ID,必须和期望值一致
- flags(1 字节):包含 UP(用户存在)、UV(用户验证)、AT(附带凭证数据)等标志位
- signCount(4 字节):凭证计数器,递增或允许 0(不支持计数器的认证器)
解析
clientDataJSON:- type:必须是
"webauthn.create"(注册)或"webauthn.get"(认证) - challenge:必须等于服务端之前缓存的挑战
- origin:必须等于允许的来源列表中的某个值
- crossOrigin(可选):跨源请求时是 true,必须根据场景判断
- type:必须是
拼接签名数据并验签:
签名数据 = 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 注册必须在已登录状态下发起,否则会创建一个"孤立账号"。好的做法是:
- 用户在登录后才能添加 Passkey
- 或者,在注册完 Passkey 后,提示用户"是否关联到已有账号"
- 在 Passkey 的
user.id字段中嵌入关联标识(推荐使用 UUID,不推荐用邮箱)
8.3 跨设备同步的挑战
Passkeys 的同步机制是"平台锁定"的:
| 平台 | 同步方式 | 问题 |
|---|---|---|
| Apple | iCloud Keychain | 仅限 Apple 设备 |
| Google Password Manager | 仅限 Chrome/Android | |
| Microsoft | Microsoft 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 年的趋势:
密码管理器的消亡:当操作系统级 Passkey 同步变得流畅后,第三方密码管理器逐步退化为"企业管理员"和"跨平台桥梁"。
多因子融合:Passkeys 本身就是"双因素"——你拥有的(设备)和你就是的(生物识别)。这叫做"内置多因子"。不再需要分开管理密码 + 验证码。
Passkey 授权(不只是认证):未来的 WebAuthn 扩展将支持用 Passkey 做交易签名、授权 API 调用、签署文档等——不限于登录。
恢复机制标准化:当前最大的痛点——设备丢失后恢复 Passkeys——正在被各平台解决。Apple 的 Recovery Contact、Google 的 Account Recovery 都在做这件事。期待一个跨平台的恢复协议。
服务端 WebAuthn:不仅浏览器能调用,服务端也能通过安全芯片调用 TPM/SE 来签名。这意味着机器身份、CI/CD 管道身份都可以用 Passkey 保护。
十二、总结
WebAuthn / Passkeys 不是"银弹",它不能解决所有安全问题。它解决的是认证凭证泄露这个最广泛的安全威胁。而且它做得很好——基于非对称加密的设计从根本上避免了密码时代的大部分攻击向量。
从实现角度看,WebAuthn 协议的复杂性是最大的门槛。但好在成熟的库(go-webauthn、simplewebauthn、py_webauthn)已经将这个复杂性封装起来。真正需要你自己处理的是:用户体验设计(Conditional Mediation 的落地)、迁移策略(传统密码到 Passkeys 的平滑过渡)、以及恢复机制(用户丢失设备后的补救)。
如果你的网站还没有接入 Passkeys,现在就是最好的时机。浏览器支持已经成熟,用户期望正在形成,技术栈也已经就绪。
参考资源: