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大挑戰

  1. 跨瀏覽器相容:Safari/Chrome/Firefox的WebAuthn實現有微妙差異
  2. 多裝置同步:Passkey依賴雲端同步(iCloud Keychain/Google Password Manager),跨生態不互通
  3. 條件UI(Conditional UI):Autofill UI整合需要特定API呼叫方式
  4. 降級策略:不支援Passkey的裝置/瀏覽器需要回退到傳統認證
  5. 服務端實現複雜: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的裝置無縫回退。


線上工具推薦

本站提供瀏覽器本地工具,免註冊即可試用 →

#WebAuthn#Passkey#无密码认证#FIDO2#生物识别#前端安全#身份认证#CTAP