WebAuthn Passkey部署:2026年無密碼認證從開發到生產的完整實戰
前端安全
密碼認證的末日到了
使用者用123456當密碼,被撞庫後怪你安全做得差;你強制複雜密碼,使用者就寫在便條紙上貼顯示器;你加了簡訊驗證碼,又被SIM Swap攻擊截獲。2026年,Passkey(通行密鑰) 終於讓「無密碼認證」從概念走向生產——Apple、Google、Microsoft全面支援,瀏覽器覆蓋率超過95%。
本文將從WebAuthn協定原理出發,帶你完成Passkey註冊→驗證→多裝置同步→降級策略的全鏈路實戰。
WebAuthn協定核心概念
| 概念 | 說明 |
|---|---|
| Relying Party (RP) | 依賴方,即你的網站/服務端 |
| Authenticator | 認證器,分平臺認證器(TouchID/FaceID)和漫遊認證器(YubiKey) |
| Credential | 憑證,公鑰密碼學中的密鑰對,私鑰存在認證器中永不離開 |
| Challenge | 服務端產生的隨機挑戰值,防重放攻擊 |
| Attestation | 證明,認證器向RP證明其合法性的方式 |
| Client Data | 客戶端資料,包含challenge、origin等 |
| Assertion | 斷言,認證時認證器返回的簽名證明 |
註冊與驗證流程
註冊流程(Registration):
1. 使用者點擊「註冊Passkey」
2. 服務端產生challenge,返回PublicKeyCredentialCreationOptions
3. 瀏覽器呼叫navigator.credentials.create()
4. 認證器產生密鑰對,私鑰儲存在安全晶片
5. 返回PublicKeyCredential(含公鑰、attestation)
6. 服務端驗證attestation,儲存公鑰和credentialID
驗證流程(Authentication):
1. 使用者點擊「使用Passkey登入」
2. 服務端產生challenge,返回PublicKeyCredentialRequestOptions
3. 瀏覽器呼叫navigator.credentials.get()
4. 認證器用私鑰簽名challenge
5. 返回PublicKeyCredential(含簽名assertion)
6. 服務端用儲存的公鑰驗證簽名
問題分析:Passkey部署的5大挑戰
- 跨瀏覽器相容:Safari/Chrome/Firefox的WebAuthn實現有微妙差異
- 多裝置同步:Passkey依賴雲端同步(iCloud Keychain/Google Password Manager),跨生態不互通
- 條件UI(Conditional UI):Autofill UI整合需要特定API呼叫方式
- 降級策略:不支援Passkey的裝置/瀏覽器需要回退到傳統認證
- 服務端實現複雜:COSE密鑰格式、CBOR編碼、簽名驗證邏輯繁瑣
分步實操:完整Passkey實現
Step 1:服務端——產生註冊選項
package webauthn
import (
"crypto/rand"
"encoding/base64"
)
type RegistrationOptions struct {
Challenge string `json:"challenge"`
RpID string `json:"rpId"`
RpName string `json:"rpName"`
UserID string `json:"userId"`
UserName string `json:"userName"`
UserDisplayName string `json:"userDisplayName"`
Timeout int `json:"timeout"`
Attestation string `json:"attestation"`
AuthenticatorSel AuthSel `json:"authenticatorSelection"`
PubKeyCredParams []CredParam `json:"pubKeyCredParams"`
ExcludeCreds []CredDescriptor `json:"excludeCredentials"`
}
type AuthSel struct {
AuthenticatorAttachment string `json:"authenticatorAttachment"`
RequireResidentKey bool `json:"requireResidentKey"`
ResidentKey string `json:"residentKey"`
UserVerification string `json:"userVerification"`
}
type CredParam struct {
Type string `json:"type"`
Alg int `json:"alg"`
}
type CredDescriptor struct {
Type string `json:"type"`
ID string `json:"id"`
}
func GenerateRegistrationOptions(rpID, rpName, userID, userName, displayName string, excludeCreds []string) (*RegistrationOptions, error) {
challenge := make([]byte, 32)
if _, err := rand.Read(challenge); err != nil {
return nil, err
}
excludeCredentials := make([]CredDescriptor, len(excludeCreds))
for i, id := range excludeCreds {
excludeCredentials[i] = CredDescriptor{Type: "public-key", ID: id}
}
return &RegistrationOptions{
Challenge: base64.RawURLEncoding.EncodeToString(challenge),
RpID: rpID,
RpName: rpName,
UserID: base64.RawURLEncoding.EncodeToString([]byte(userID)),
UserName: userName,
UserDisplayName: displayName,
Timeout: 60000,
Attestation: "none",
AuthenticatorSel: AuthSel{
AuthenticatorAttachment: "platform",
RequireResidentKey: true,
ResidentKey: "required",
UserVerification: "preferred",
},
PubKeyCredParams: []CredParam{
{Type: "public-key", Alg: -7}, // ES256
{Type: "public-key", Alg: -257}, // RS256
},
ExcludeCreds: excludeCredentials,
}, nil
}
Step 2:服務端——驗證註冊回應
package webauthn
import (
"encoding/base64"
"encoding/json"
"fmt"
)
type RegistrationResponse struct {
ID string `json:"id"`
RawID string `json:"rawId"`
Type string `json:"type"`
Response struct {
ClientDataJSON string `json:"clientDataJSON"`
AttestationObject string `json:"attestationObject"`
} `json:"response"`
}
type ClientData struct {
Type string `json:"type"`
Challenge string `json:"challenge"`
Origin string `json:"origin"`
}
func VerifyRegistrationResponse(response *RegistrationResponse, expectedChallenge, expectedOrigin string) (*VerifiedRegistration, error) {
clientDataBytes, err := base64.RawURLEncoding.DecodeString(response.Response.ClientDataJSON)
if err != nil {
return nil, fmt.Errorf("decode client data: %w", err)
}
var clientData ClientData
if err := json.Unmarshal(clientDataBytes, &clientData); err != nil {
return nil, fmt.Errorf("parse client data: %w", err)
}
if clientData.Type != "webauthn.create" {
return nil, fmt.Errorf("unexpected type: %s", clientData.Type)
}
if clientData.Challenge != expectedChallenge {
return nil, fmt.Errorf("challenge mismatch")
}
if clientData.Origin != expectedOrigin {
return nil, fmt.Errorf("origin mismatch: got %s, want %s", clientData.Origin, expectedOrigin)
}
credID, err := base64.RawURLEncoding.DecodeString(response.ID)
if err != nil {
return nil, fmt.Errorf("decode credential ID: %w", err)
}
return &VerifiedRegistration{
CredentialID: credID,
ClientData: &clientData,
}, nil
}
type VerifiedRegistration struct {
CredentialID []byte
ClientData *ClientData
}
Step 3:前端——註冊Passkey
async function registerPasskey(options: RegistrationOptions): Promise<PublicKeyCredential | null> {
if (!window.PublicKeyCredential) {
console.error('WebAuthn is not supported');
return null;
}
const isAvailable = await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
if (!isAvailable) {
console.error('Platform authenticator not available');
return null;
}
const creationOptions: PublicKeyCredentialCreationOptions = {
challenge: base64URLDecode(options.challenge),
rp: { id: options.rpId, name: options.rpName },
user: {
id: base64URLDecode(options.userId),
name: options.userName,
displayName: options.userDisplayName,
},
pubKeyCredParams: options.pubKeyCredParams.map(p => ({
type: p.type as PublicKeyCredentialType,
alg: p.alg,
})),
timeout: options.timeout,
attestation: options.attestation as AttestationConveyancePreference,
authenticatorSelection: {
authenticatorAttachment: options.authenticatorSelection.authenticatorAttachment as AuthenticatorAttachment,
requireResidentKey: options.authenticatorSelection.requireResidentKey,
residentKey: options.authenticatorSelection.residentKey as ResidentKeyRequirement,
userVerification: options.authenticatorSelection.userVerification as UserVerificationRequirement,
},
excludeCredentials: options.excludeCredentials.map(c => ({
type: c.type as PublicKeyCredentialType,
id: base64URLDecode(c.id),
})),
};
try {
const credential = await navigator.credentials.create({ publicKey: creationOptions });
return credential as PublicKeyCredential;
} catch (err) {
if ((err as DOMException).name === 'InvalidStateError') {
console.error('Passkey already exists for this account');
}
throw err;
}
}
function base64URLDecode(str: string): ArrayBuffer {
const base64 = str.replace(/-/g, '+').replace(/_/g, '/');
const binary = atob(base64);
const buffer = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
buffer[i] = binary.charCodeAt(i);
}
return buffer.buffer;
}
Step 4:前端——驗證Passkey(含Conditional UI)
async function authenticatePasskey(rpId: string, challenge: string): Promise<PublicKeyCredential | null> {
const requestOptions: PublicKeyCredentialRequestOptions = {
challenge: base64URLDecode(challenge),
rpId: rpId,
timeout: 60000,
userVerification: 'preferred',
allowCredentials: [],
};
try {
const credential = await navigator.credentials.get({ publicKey: requestOptions });
return credential as PublicKeyCredential;
} catch (err) {
console.error('Authentication failed:', err);
return null;
}
}
async function authenticateWithAutofill(rpId: string, challenge: string): Promise<void> {
if (!window.PublicKeyCredential) return;
const isConditionalAvailable = 'isConditionalMediationAvailable' in PublicKeyCredential
&& await (PublicKeyCredential as any).isConditionalMediationAvailable();
if (!isConditionalAvailable) return;
const requestOptions: PublicKeyCredentialRequestOptions = {
challenge: base64URLDecode(challenge),
rpId: rpId,
timeout: 60000,
userVerification: 'preferred',
allowCredentials: [],
};
try {
const credential = await navigator.credentials.get({
publicKey: requestOptions,
mediation: 'conditional' as CredentialMediationRequirement,
});
if (credential) {
await sendAssertionToServer(credential as PublicKeyCredential);
}
} catch (err) {
console.error('Conditional UI auth failed:', err);
}
}
Step 5:降級策略
async function login(): Promise<void> {
const hasWebAuthn = !!window.PublicKeyCredential;
const hasPlatformAuth = hasWebAuthn
&& await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
if (hasPlatformAuth) {
try {
const credential = await authenticatePasskey(rpId, challenge);
if (credential) {
await sendAssertionToServer(credential);
return;
}
} catch {
// Passkey認證失敗,降級
}
}
await fallbackToPasswordLogin();
}
避坑指南
坑1:challenge沒有用base64url編碼
// ❌ 錯誤:直接傳hex字串
const challenge = crypto.randomUUID();
// ✅ 正確:產生隨機位元組後base64url編碼
const challengeBuffer = new Uint8Array(32);
crypto.getRandomValues(challengeBuffer);
const challenge = arrayBufferToBase64URL(challengeBuffer);
坑2:RP ID與域名不匹配
// ❌ 錯誤:RP ID用了完整URL
const rpId = 'https://auth.example.com';
// ✅ 正確:RP ID只用有效域名
const rpId = 'example.com';
坑3:requireResidentKey設為false導致Passkey不可發現
// ❌ 錯誤:非駐留密鑰無法在Conditional UI中顯示
authenticatorSelection: {
requireResidentKey: false,
residentKey: 'discouraged',
}
// ✅ 正確:Passkey必須是駐留密鑰
authenticatorSelection: {
requireResidentKey: true,
residentKey: 'required',
}
坑4:忘記處理使用者取消操作
// ❌ 錯誤:不處理NotAllowedError
const credential = await navigator.credentials.create({ publicKey: options });
// ✅ 正確:捕獲使用者取消
try {
const credential = await navigator.credentials.create({ publicKey: options });
} catch (err) {
if ((err as DOMException).name === 'NotAllowedError') {
showToast('操作已取消,請重試');
return;
}
throw err;
}
坑5:attestation設為"direct"導致使用者隱私警告
// ❌ 錯誤:要求直接證明,瀏覽器會彈出隱私警告
attestation: 'direct'
// ✅ 正確:生產環境用"none",除非需要裝置證明
attestation: 'none'
報錯排查
| 序號 | 報錯訊息 | 原因 | 解決方法 |
|---|---|---|---|
| 1 | The operation either timed out or was not allowed |
使用者取消或超時 | 捕獲NotAllowedError,提示使用者重試 |
| 2 | An authenticator appropriate for this request could not be found |
無可用認證器 | 檢查isUserVerifyingPlatformAuthenticatorAvailable,提供降級 |
| 3 | At least one credential is required |
allowCredentials為空且無駐留密鑰 | 確保註冊時residentKey=required |
| 4 | The RP ID is not a registrable domain suffix |
RP ID格式錯誤 | 使用有效域名,不含協定和連接埠 |
| 5 | The challenge is not a ArrayBuffer |
challenge型別錯誤 | 確保challenge是ArrayBuffer,不是字串 |
| 6 | A public key credential with the specified ID already exists |
重複註冊同一憑證 | 使用excludeCredentials排除已註冊憑證 |
| 7 | The authenticator cannot create a credential |
認證器不支援請求的演算法 | 新增ES256(-7)和RS256(-257)兩種演算法 |
| 8 | The operation could not be completed |
HTTPS未配置 | WebAuthn要求HTTPS(localhost除外) |
| 9 | The request could not be satisfied |
authenticatorSelection約束不滿足 | 放寬約束或提供cross-platform選項 |
| 10 | CBOR decoding failed |
服務端attestationObject解析錯誤 | 使用標準CBOR庫解析,檢查base64url解碼 |
進階最佳化
1. 多裝置Passkey同步偵測
async function checkPasskeySyncStatus(credentialId: string): Promise<'synced' | 'local' | 'unknown'> {
if ('isExternalPasskeySynced' in PublicKeyCredential) {
const isSynced = await (PublicKeyCredential as any).isExternalPasskeySynced({
credentialId: base64URLDecode(credentialId),
});
return isSynced ? 'synced' : 'local';
}
return 'unknown';
}
2. Passkey註冊率追蹤
interface PasskeyMetrics {
webauthnSupported: boolean;
platformAuthAvailable: boolean;
conditionalUIAvailable: boolean;
registrationAttempted: boolean;
registrationSucceeded: boolean;
registrationError: string | null;
}
function collectPasskeyMetrics(): PasskeyMetrics {
return {
webauthnSupported: !!window.PublicKeyCredential,
platformAuthAvailable: false,
conditionalUIAvailable: false,
registrationAttempted: false,
registrationSucceeded: false,
registrationError: null,
};
}
3. AAGUID過濾——限制認證器型別
var allowedAAGUIDs = map[string]bool{
"08987058-5a24-4c8b-8f4b-3dd4d5f5d5f5": true, // Apple TouchID
"de1e552d-db1d-4423-a619-3db8da29e0b0": true, // Android Fingerprint
"f8a011f3-8c0a-4d15-aa6b-0040a2c8e6e0": true, // Windows Hello
}
func isAllowedAuthenticator(aaguid string) bool {
return allowedAAGUIDs[aaguid]
}
對比分析
| 維度 | Passkey/WebAuthn | 傳統密碼 | OTP/TOTP | OAuth2/OIDC | 硬體Token |
|---|---|---|---|---|---|
| 釣魚抵抗 | ✅強 | ❌弱 | ⚠️中 | ⚠️中 | ✅強 |
| 使用者體驗 | ⭐極好 | ⭐差 | ⭐中 | ⭐好 | ⭐差 |
| 多裝置同步 | ✅雲端 | ✅手動 | ❌需額外APP | ✅ | ❌ |
| 實現複雜度 | 高 | 低 | 中 | 中 | 高 |
| 離線可用 | ✅ | ✅ | ✅ | ❌ | ✅ |
| 隱私保護 | ✅無追蹤 | ❌ | ⚠️ | ❌ | ✅ |
| 瀏覽器支援 | 95%+ | 100% | 100% | 100% | 需USB/NFC |
| 成本 | 低 | 低 | 中 | 中 | 高($25-60/個) |
總結:Passkey不是「密碼的替代品」,而是「釣魚攻擊的終結者」。WebAuthn的公鑰密碼學從根本上消除了憑證洩露風險——私鑰永不離開裝置,challenge-response機制杜絕重放和釣魚。2026年的部署路徑:先實現Conditional UI(Autofill整合,零使用者學習成本)→再推Passkey註冊引導→最後逐步下線密碼登入。關鍵是要有完善的降級策略,讓不支援Passkey的裝置無縫回退。
線上工具推薦
- Base64編解碼:/zh-TW/encode/base64
- JSON格式化:/zh-TW/json/format
- JWT解碼:/zh-TW/encode/jwt-decode
- Hash計算:/zh-TW/encode/hash
本站提供瀏覽器本地工具,免註冊即可試用 →
#WebAuthn#Passkey#无密码认证#FIDO2#生物识别#前端安全#身份认证#CTAP