WebAuthn FIDO2實作實戰:從註冊到無密碼認證的6種生產模式

安全

密碼已死,Passkey當立

2026年,密碼洩露事件仍在以每月數億條的速度增長。釣魚攻擊繞過傳統MFA,SMS OTP被SIM Swap截獲,TOTP令牌用戶因「MFA疲勞攻擊」而盲目點擊允許。Passkey(通行密鑰) 基於FIDO2/WebAuthn標準,用公鑰密碼學+裝置生物識別徹底消滅密碼——Apple、Google、Microsoft三大生態全面互通,瀏覽器覆蓋率超過96%。

本文將給出6種生產級模式:從Passkey註冊到認證流程,憑證管理到多裝置支援,回退策略到完整Python+JavaScript生產服務,每段程式碼可直接執行。


WebAuthn/FIDO2核心概念

概念 說明
WebAuthn W3C標準,瀏覽器提供的navigator.credentials API,定義了註冊和認證的JavaScript介面
FIDO2 FIDO Alliance標準,包含CTAP2協定,規範認證器與服務端的通訊
Passkey 可跨裝置同步的FIDO2憑證,儲存在iCloud Keychain/Google Password Manager中
Authenticator 認證器,分平台認證器(TouchID/FaceID/Windows Hello)和漫遊認證器(YubiKey)
Relying Party (RP) 依賴方,即你的網站/服務端,透過rpID識別
Credential 憑證,公鑰密碼學中的密鑰對,私鑰永遠不離開認證器安全晶片
Attestation 證明,註冊時認證器向RP證明其安全性和合法性(可選用none)
Assertion 斷言,認證時認證器用私鑰簽名challenge後回傳的簽名證明

問題分析:WebAuthn生產落地的5大挑戰

  1. 註冊流程複雜:服務端需產生正確的PublicKeyCredentialCreationOptions,COSE演算法識別、authenticatorSelection參數設定極易出錯
  2. 認證流程安全:challenge必須真隨機、單次使用,簽名驗證需處理CBOR/COSE編碼,遺漏任何校驗即產生安全漏洞
  3. 憑證生命週期管理:使用者可能註冊多個Passkey,需支援列表展示、刪除、重新命名,遺失所有裝置時需恢復機制
  4. 跨平台與多裝置:iCloud Passkey與Google Passkey不互通,企業場景需支援安全金鑰(YubiKey),條件UI(Conditional UI)整合複雜
  5. 降級與回退:老舊裝置不支援WebAuthn,必須保留密碼回退,混合認證流程的使用者體驗設計極具挑戰

6種生產模式實戰

Pattern 1:Passkey註冊流程

服務端(Python + py_webauthn)

from py_webauthn import WebAuthnRP, WebAuthnUser, WebAuthnCredential
from py_webauthn.helpers.structs import (
    AuthenticatorSelectionCriteria,
    UserVerificationRequirement,
    AuthenticatorAttachment,
    ResidentKeyRequirement,
    PublicKeyCredentialDescriptor,
    AttestationConveyancePreference,
)
import secrets
import base64

class PasskeyRegistrationService:
    def __init__(self, rp_id: str, rp_name: str, origin: str):
        self.rp_id = rp_id
        self.rp_name = rp_name
        self.origin = origin

    def generate_registration_options(
        self,
        user_id: str,
        username: str,
        display_name: str,
        exclude_credentials: list[dict] | None = None,
    ) -> dict:
        challenge = secrets.token_bytes(32)
        challenge_b64 = base64.urlsafe_b64encode(challenge).rstrip(b"=").decode()

        exclude_list = []
        if exclude_credentials:
            for cred in exclude_credentials:
                exclude_list.append(
                    PublicKeyCredentialDescriptor(
                        id=base64.urlsafe_b64decode(cred["id"] + "=="),
                        type="public-key",
                    )
                )

        options = {
            "challenge": challenge_b64,
            "rp": {"id": self.rp_id, "name": self.rp_name},
            "user": {
                "id": base64.urlsafe_b64encode(user_id.encode()).rstrip(b"=").decode(),
                "name": username,
                "displayName": display_name,
            },
            "pubKeyCredParams": [
                {"type": "public-key", "alg": -7},
                {"type": "public-key", "alg": -257},
            ],
            "timeout": 60000,
            "excludeCredentials": [
                {
                    "type": ec.type,
                    "id": base64.urlsafe_b64encode(ec.id).rstrip(b"=").decode(),
                }
                for ec in exclude_list
            ],
            "authenticatorSelection": {
                "authenticatorAttachment": "platform",
                "requireResidentKey": True,
                "residentKey": "required",
                "userVerification": "required",
            },
            "attestation": "none",
        }

        return options, challenge_b64

    def verify_registration(
        self,
        challenge_b64: str,
        credential_response: dict,
        expected_origin: str,
    ) -> dict:
        from py_webauthn import verify_registration_response

        verification = verify_registration_response(
            credential=credential_response,
            expected_challenge=base64.urlsafe_b64encode(
                base64.urlsafe_b64decode(challenge_b64 + "==")
            ).rstrip(b"=").decode(),
            expected_origin=expected_origin,
            expected_rp_id=self.rp_id,
        )

        return {
            "credential_id": base64.urlsafe_b64encode(
                verification.credential_id
            ).rstrip(b"=").decode(),
            "public_key": base64.urlsafe_b64encode(
                verification.credential_public_key
            ).rstrip(b"=").decode(),
            "sign_count": verification.sign_count,
        }

客戶端(JavaScript)

async function registerPasskey(userId, username, displayName) {
  const excludeCredentials = await getExistingCredentials(userId);

  const optionsResponse = await fetch("/api/webauthn/registration/options", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      userId,
      username,
      displayName,
      excludeCredentials,
    }),
  });

  const { options, challenge } = await optionsResponse.json();

  const publicKeyCredentialCreationOptions = {
    ...options,
    challenge: base64urlDecode(options.challenge),
    user: {
      ...options.user,
      id: base64urlDecode(options.user.id),
    },
    excludeCredentials: options.excludeCredentials.map((ec) => ({
      ...ec,
      id: base64urlDecode(ec.id),
    })),
  };

  try {
    const credential = await navigator.credentials.create({
      publicKey: publicKeyCredentialCreationOptions,
    });

    const verificationPayload = {
      id: credential.id,
      rawId: base64urlEncode(credential.rawId),
      type: credential.type,
      response: {
        attestationObject: base64urlEncode(
          credential.response.attestationObject
        ),
        clientDataJSON: base64urlEncode(credential.response.clientDataJSON),
      },
    };

    const verifyResponse = await fetch("/api/webauthn/registration/verify", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        challenge,
        credential: verificationPayload,
      }),
    });

    const result = await verifyResponse.json();
    if (result.success) {
      showToast("Passkey註冊成功!");
    }
  } catch (error) {
    if (error.name === "InvalidStateError") {
      showToast("該裝置已註冊Passkey,請勿重複註冊");
    } else if (error.name === "NotAllowedError") {
      showToast("使用者取消了註冊操作");
    } else {
      showToast(`註冊失敗:${error.message}`);
    }
  }
}

function base64urlDecode(str) {
  const padding = "=".repeat((4 - (str.length % 4)) % 4);
  const base64 = str.replace(/-/g, "+").replace(/_/g, "/") + padding;
  const binaryStr = atob(base64);
  return Uint8Array.from(binaryStr, (c) => c.charCodeAt(0));
}

function base64urlEncode(buffer) {
  const bytes = new Uint8Array(buffer);
  let binary = "";
  bytes.forEach((b) => (binary += String.fromCharCode(b)));
  return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
}

Pattern 2:認證流程(WebAuthn API)

服務端

class PasskeyAuthService:
    def __init__(self, rp_id: str, origin: str):
        self.rp_id = rp_id
        self.origin = origin

    def generate_authentication_options(
        self,
        allow_credentials: list[dict] | None = None,
    ) -> dict:
        challenge = secrets.token_bytes(32)
        challenge_b64 = base64.urlsafe_b64encode(challenge).rstrip(b"=").decode()

        allow_list = []
        if allow_credentials:
            for cred in allow_credentials:
                allow_list.append(
                    PublicKeyCredentialDescriptor(
                        id=base64.urlsafe_b64decode(cred["id"] + "=="),
                        type="public-key",
                    )
                )

        options = {
            "challenge": challenge_b64,
            "rpId": self.rp_id,
            "timeout": 60000,
            "allowCredentials": [
                {
                    "type": ac.type,
                    "id": base64.urlsafe_b64encode(ac.id).rstrip(b"=").decode(),
                }
                for ac in allow_list
            ],
            "userVerification": "required",
        }

        return options, challenge_b64

    def verify_authentication(
        self,
        challenge_b64: str,
        credential_response: dict,
        stored_credential: dict,
    ) -> bool:
        from py_webauthn import verify_authentication_response

        verification = verify_authentication_response(
            credential=credential_response,
            expected_challenge=base64.urlsafe_b64encode(
                base64.urlsafe_b64decode(challenge_b64 + "==")
            ).rstrip(b"=").decode(),
            expected_origin=self.origin,
            expected_rp_id=self.rp_id,
            credential_public_key=base64.urlsafe_b64decode(
                stored_credential["public_key"] + "=="
            ),
            credential_current_sign_count=stored_credential["sign_count"],
        )

        return verification.new_sign_count

客戶端

async function authenticateWithPasskey() {
  const optionsResponse = await fetch("/api/webauthn/authentication/options", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
  });

  const { options, challenge } = await optionsResponse.json();

  const publicKeyCredentialRequestOptions = {
    ...options,
    challenge: base64urlDecode(options.challenge),
    allowCredentials: options.allowCredentials?.map((ac) => ({
      ...ac,
      id: base64urlDecode(ac.id),
    })),
  };

  try {
    const assertion = await navigator.credentials.get({
      publicKey: publicKeyCredentialRequestOptions,
    });

    const verificationPayload = {
      id: assertion.id,
      rawId: base64urlEncode(assertion.rawId),
      type: assertion.type,
      response: {
        authenticatorData: base64urlEncode(
          assertion.response.authenticatorData
        ),
        clientDataJSON: base64urlEncode(assertion.response.clientDataJSON),
        signature: base64urlEncode(assertion.response.signature),
        userHandle: assertion.response.userHandle
          ? base64urlEncode(assertion.response.userHandle)
          : null,
      },
    };

    const verifyResponse = await fetch(
      "/api/webauthn/authentication/verify",
      {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          challenge,
          credential: verificationPayload,
        }),
      }
    );

    const result = await verifyResponse.json();
    if (result.success) {
      window.location.href = "/dashboard";
    }
  } catch (error) {
    if (error.name === "NotAllowedError") {
      showToast("認證已取消");
    } else {
      showToast(`認證失敗:${error.message}`);
    }
  }
}

條件UI(Autofill)模式

async function setupConditionalUI() {
  if (!window.PublicKeyCredential) return;

  const available = await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
  if (!available) return;

  const optionsResponse = await fetch(
    "/api/webauthn/authentication/options",
    {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ conditionalUI: true }),
    }
  );

  const { options, challenge } = await optionsResponse.json();

  const publicKeyCredentialRequestOptions = {
    ...options,
    challenge: base64urlDecode(options.challenge),
    mediation: "conditional",
  };

  try {
    const assertion = await navigator.credentials.get({
      publicKey: publicKeyCredentialRequestOptions,
    });
    // 處理認證結果,同上
  } catch (error) {
    // 條件UI模式下使用者未選擇Passkey是正常行為,靜默處理
  }
}

// 頁面載入時自動設定
document.addEventListener("DOMContentLoaded", () => {
  const usernameInput = document.getElementById("username");
  if (usernameInput) {
    usernameInput.setAttribute("autocomplete", "username webauthn");
    setupConditionalUI();
  }
});

Pattern 3:憑證管理(列表、刪除、更新)

服務端

from dataclasses import dataclass
from datetime import datetime

@dataclass
class PasskeyCredential:
    credential_id: str
    user_id: str
    public_key: str
    sign_count: int
    name: str
    device_type: str
    created_at: datetime
    last_used_at: datetime | None
    transports: list[str]

class CredentialManagementService:
    def __init__(self, db_session):
        self.db = db_session

    def list_credentials(self, user_id: str) -> list[dict]:
        credentials = self.db.query(PasskeyCredential).filter(
            PasskeyCredential.user_id == user_id
        ).order_by(PasskeyCredential.created_at.desc()).all()

        return [
            {
                "id": cred.credential_id,
                "name": cred.name or f"Passkey #{idx + 1}",
                "deviceType": cred.device_type,
                "createdAt": cred.created_at.isoformat(),
                "lastUsedAt": cred.last_used_at.isoformat() if cred.last_used_at else None,
                "transports": cred.transports,
            }
            for idx, cred in enumerate(credentials)
        ]

    def delete_credential(self, user_id: str, credential_id: str) -> bool:
        cred = self.db.query(PasskeyCredential).filter(
            PasskeyCredential.user_id == user_id,
            PasskeyCredential.credential_id == credential_id,
        ).first()

        if not cred:
            return False

        remaining_count = self.db.query(PasskeyCredential).filter(
            PasskeyCredential.user_id == user_id
        ).count()

        if remaining_count <= 1:
            raise ValueError("不能刪除最後一個Passkey,請先註冊備用憑證")

        self.db.delete(cred)
        self.db.commit()
        return True

    def rename_credential(self, user_id: str, credential_id: str, new_name: str) -> bool:
        cred = self.db.query(PasskeyCredential).filter(
            PasskeyCredential.user_id == user_id,
            PasskeyCredential.credential_id == credential_id,
        ).first()

        if not cred:
            return False

        cred.name = new_name[:64]
        self.db.commit()
        return True

    def update_sign_count(self, credential_id: str, new_sign_count: int) -> None:
        cred = self.db.query(PasskeyCredential).filter(
            PasskeyCredential.credential_id == credential_id
        ).first()

        if cred:
            cred.sign_count = new_sign_count
            cred.last_used_at = datetime.utcnow()
            self.db.commit()

客戶端

class PasskeyManager {
  constructor(userId) {
    this.userId = userId;
  }

  async listCredentials() {
    const response = await fetch(`/api/webauthn/credentials?userId=${this.userId}`);
    return response.json();
  }

  async deleteCredential(credentialId) {
    const confirmed = confirm("確定要刪除此Passkey嗎?刪除後無法恢復。");
    if (!confirmed) return false;

    const response = await fetch("/api/webauthn/credentials/delete", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        userId: this.userId,
        credentialId,
      }),
    });

    const result = await response.json();
    if (result.success) {
      this.renderCredentialList();
    }
    return result.success;
  }

  async renameCredential(credentialId, newName) {
    const response = await fetch("/api/webauthn/credentials/rename", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        userId: this.userId,
        credentialId,
        newName,
      }),
    });

    return (await response.json()).success;
  }

  async renderCredentialList() {
    const credentials = await this.listCredentials();
    const container = document.getElementById("passkey-list");

    container.innerHTML = credentials
      .map(
        (cred) => `
      <div class="passkey-item" data-id="${cred.id}">
        <div class="passkey-info">
          <span class="passkey-name">${cred.name}</span>
          <span class="passkey-device">${cred.deviceType}</span>
          <span class="passkey-date">
            建立於 ${new Date(cred.createdAt).toLocaleDateString()}
            ${cred.lastUsedAt ? `· 上次使用 ${new Date(cred.lastUsedAt).toLocaleDateString()}` : ""}
          </span>
        </div>
        <div class="passkey-actions">
          <button onclick="passkeyManager.renameCredential('${cred.id}', prompt('新名稱:', '${cred.name}'))">
            重新命名
          </button>
          <button onclick="passkeyManager.deleteCredential('${cred.id}')">
            刪除
          </button>
        </div>
      </div>
    `
      )
      .join("");
  }
}

Pattern 4:多裝置與跨平台支援

服務端

class MultiDevicePasskeyService:
    SUPPORTED_ALGORITHMS = [-7, -257, -37]

    def generate_inclusive_registration_options(
        self,
        user_id: str,
        username: str,
        display_name: str,
        existing_credentials: list[dict],
    ) -> dict:
        challenge = secrets.token_bytes(32)
        challenge_b64 = base64.urlsafe_b64encode(challenge).rstrip(b"=").decode()

        exclude_list = [
            {
                "type": "public-key",
                "id": cred["credential_id"],
            }
            for cred in existing_credentials
        ]

        options = {
            "challenge": challenge_b64,
            "rp": {"id": self.rp_id, "name": self.rp_name},
            "user": {
                "id": base64.urlsafe_b64encode(user_id.encode()).rstrip(b"=").decode(),
                "name": username,
                "displayName": display_name,
            },
            "pubKeyCredParams": [
                {"type": "public-key", "alg": alg}
                for alg in self.SUPPORTED_ALGORITHMS
            ],
            "timeout": 120000,
            "excludeCredentials": exclude_list,
            "authenticatorSelection": {
                "authenticatorAttachment": "platform",
                "requireResidentKey": True,
                "residentKey": "required",
                "userVerification": "preferred",
            },
            "attestation": "none",
        }

        return options, challenge_b64

    def generate_security_key_options(
        self,
        user_id: str,
        username: str,
        display_name: str,
    ) -> dict:
        challenge = secrets.token_bytes(32)
        challenge_b64 = base64.urlsafe_b64encode(challenge).rstrip(b"=").decode()

        options = {
            "challenge": challenge_b64,
            "rp": {"id": self.rp_id, "name": self.rp_name},
            "user": {
                "id": base64.urlsafe_b64encode(user_id.encode()).rstrip(b"=").decode(),
                "name": username,
                "displayName": display_name,
            },
            "pubKeyCredParams": [
                {"type": "public-key", "alg": -7},
                {"type": "public-key", "alg": -257},
            ],
            "timeout": 120000,
            "authenticatorSelection": {
                "authenticatorAttachment": "cross-platform",
                "requireResidentKey": False,
                "residentKey": "discouraged",
                "userVerification": "preferred",
            },
            "attestation": "direct",
        }

        return options, challenge_b64

客戶端

async function registerPlatformPasskey(userId, username, displayName) {
  const existingCreds = await getExistingCredentials(userId);
  const { options, challenge } = await fetchRegistrationOptions({
    userId,
    username,
    displayName,
    existingCredentials: existingCreds,
    type: "platform",
  });

  return executeRegistration(options, challenge);
}

async function registerSecurityKey(userId, username, displayName) {
  const { options, challenge } = await fetchRegistrationOptions({
    userId,
    username,
    displayName,
    type: "cross-platform",
  });

  return executeRegistration(options, challenge);
}

async function detectPasskeySupport() {
  if (!window.PublicKeyCredential) {
    return {
      supported: false,
      platformAuthenticator: false,
      conditionalUI: false,
    };
  }

  const platformAuth = await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();

  let conditionalUI = false;
  if (platformAuth && PublicKeyCredential.isConditionalMediationAvailable) {
    conditionalUI = await PublicKeyCredential.isConditionalMediationAvailable();
  }

  return {
    supported: true,
    platformAuthenticator: platformAuth,
    conditionalUI,
  };
}

async function renderRegistrationUI(userId, username, displayName) {
  const support = await detectPasskeySupport();

  const container = document.getElementById("passkey-registration");

  if (support.platformAuthenticator) {
    container.innerHTML += `
      <button onclick="registerPlatformPasskey('${userId}', '${username}', '${displayName}')">
        註冊Passkey(手機/電腦生物識別)
      </button>
    `;
  }

  container.innerHTML += `
    <button onclick="registerSecurityKey('${userId}', '${username}', '${displayName}')">
      註冊安全金鑰(YubiKey等)
    </button>
  `;

  if (!support.supported) {
    container.innerHTML = `
      <p>您的瀏覽器不支援WebAuthn,請使用密碼登入。</p>
    `;
  }
}

Pattern 5:回退策略(密碼+Passkey混合)

服務端

class HybridAuthService:
    def __init__(self, db_session, rp_id: str, origin: str):
        self.db = db_session
        self.rp_id = rp_id
        self.origin = origin

    def get_login_options(self, user_id: str | None = None) -> dict:
        if user_id:
            credentials = self._get_user_credentials(user_id)
            has_passkey = len(credentials) > 0

            if has_passkey:
                auth_options, challenge = PasskeyAuthService(
                    self.rp_id, self.origin
                ).generate_authentication_options(
                    allow_credentials=[
                        {"id": c.credential_id} for c in credentials
                    ]
                )
                return {
                    "mode": "passkey_preferred",
                    "passkeyOptions": auth_options,
                    "challenge": challenge,
                    "passwordFallback": True,
                }

        return {
            "mode": "password_only",
            "passkeyOptions": None,
            "challenge": None,
            "passwordFallback": True,
        }

    def promote_to_passkey(self, user_id: str) -> dict:
        user = self._get_user(user_id)
        existing_creds = self._get_user_credentials(user_id)

        reg_options, challenge = PasskeyRegistrationService(
            self.rp_id, "MyApp", self.origin
        ).generate_registration_options(
            user_id=user_id,
            username=user.username,
            display_name=user.display_name,
            exclude_credentials=[
                {"id": c.credential_id} for c in existing_creds
            ],
        )

        return {
            "registrationOptions": reg_options,
            "challenge": challenge,
            "message": "升級您的帳戶:註冊Passkey以獲得更安全的無密碼登入體驗",
        }

客戶端

class HybridLoginFlow {
  constructor() {
    this.step = "initial";
  }

  async init() {
    const support = await detectPasskeySupport();

    if (support.conditionalUI) {
      this.setupAutofillPasskey();
    }

    this.renderLoginForm(support);
  }

  renderLoginForm(support) {
    const form = document.getElementById("login-form");

    form.innerHTML = `
      <input type="text" id="username" name="username"
        autocomplete="username webauthn"
        placeholder="使用者名稱" required />

      <div id="passkey-section" style="display:none">
        <button type="button" id="passkey-login-btn">
          使用Passkey登入
        </button>
        <p class="divider-text">或使用密碼</p>
      </div>

      <div id="password-section">
        <input type="password" id="password" name="password"
          autocomplete="current-password"
          placeholder="密碼" />
        <button type="submit">登入</button>
      </div>
    `;

    document.getElementById("username").addEventListener("blur", async () => {
      const username = document.getElementById("username").value;
      if (!username) return;

      const check = await fetch("/api/auth/check", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ username }),
      });

      const { hasPasskey } = await check.json();
      if (hasPasskey && support.platformAuthenticator) {
        document.getElementById("passkey-section").style.display = "block";
      }
    });

    document.getElementById("passkey-login-btn").addEventListener(
      "click",
      () => this.authenticateWithPasskey()
    );

    form.addEventListener("submit", (e) => {
      e.preventDefault();
      this.authenticateWithPassword();
    });
  }

  async setupAutofillPasskey() {
    const { options, challenge } = await fetch(
      "/api/webauthn/authentication/options",
      {
        method: "POST",
        headers: { "Content-Type": "application/json" },
      }
    ).then((r) => r.json());

    try {
      const assertion = await navigator.credentials.get({
        publicKey: {
          ...options,
          challenge: base64urlDecode(options.challenge),
          mediation: "conditional",
        },
      });

      await this.verifyAssertion(assertion, challenge);
    } catch {
      // 使用者未選擇Passkey,靜默處理
    }
  }

  async authenticateWithPassword() {
    const username = document.getElementById("username").value;
    const password = document.getElementById("password").value;

    const response = await fetch("/api/auth/login", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ username, password }),
    });

    const result = await response.json();
    if (result.success) {
      if (result.suggestPasskeyUpgrade) {
        this.showPasskeyUpgradePrompt(result.userId);
      } else {
        window.location.href = "/dashboard";
      }
    }
  }

  showPasskeyUpgradePrompt(userId) {
    const upgrade = confirm(
      "偵測到您的裝置支援Passkey,是否現在註冊以獲得更安全的無密碼登入?"
    );
    if (upgrade) {
      registerPlatformPasskey(userId);
    } else {
      window.location.href = "/dashboard";
    }
  }
}

Pattern 6:完整生產認證服務(Python + JavaScript)

服務端(FastAPI + py_webauthn)

from fastapi import FastAPI, HTTPException, Depends, Request
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
import secrets
import base64
from typing import Optional

app = FastAPI(title="WebAuthn Production Auth Service")

app.add_middleware(
    CORSMiddleware,
    allow_origins=["https://yourapp.com"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

challenge_store: dict[str, dict] = {}
session_store: dict[str, dict] = {}

class RegistrationOptionsRequest(BaseModel):
    userId: str
    username: str
    displayName: str
    excludeCredentials: Optional[list[dict]] = None

class RegistrationVerifyRequest(BaseModel):
    challenge: str
    credential: dict

class AuthenticationOptionsRequest(BaseModel):
    userId: Optional[str] = None
    conditionalUI: Optional[bool] = False

class AuthenticationVerifyRequest(BaseModel):
    challenge: str
    credential: dict

RP_ID = "yourapp.com"
RP_NAME = "MyApp"
ORIGIN = "https://yourapp.com"

registration_service = PasskeyRegistrationService(RP_ID, RP_NAME, ORIGIN)
auth_service = PasskeyAuthService(RP_ID, ORIGIN)

@app.post("/api/webauthn/registration/options")
async def create_registration_options(req: RegistrationOptionsRequest):
    options, challenge = registration_service.generate_registration_options(
        user_id=req.userId,
        username=req.username,
        display_name=req.displayName,
        exclude_credentials=req.excludeCredentials,
    )

    challenge_store[challenge] = {
        "userId": req.userId,
        "type": "registration",
        "expiresAt": secrets.token_hex(8),
    }

    return {"options": options, "challenge": challenge}

@app.post("/api/webauthn/registration/verify")
async def verify_registration_endpoint(req: RegistrationVerifyRequest):
    stored = challenge_store.get(req.challenge)
    if not stored:
        raise HTTPException(status_code=400, detail="無效或已過期的challenge")

    del challenge_store[req.challenge]

    try:
        result = registration_service.verify_registration(
            challenge_b64=req.challenge,
            credential_response=req.credential,
            expected_origin=ORIGIN,
        )

        return {"success": True, "credentialId": result["credential_id"]}
    except Exception as e:
        raise HTTPException(status_code=400, detail=str(e))

@app.post("/api/webauthn/authentication/options")
async def create_authentication_options(req: AuthenticationOptionsRequest):
    allow_credentials = None
    if req.userId:
        pass

    options, challenge = auth_service.generate_authentication_options(
        allow_credentials=allow_credentials,
    )

    challenge_store[challenge] = {
        "userId": req.userId,
        "type": "authentication",
    }

    return {"options": options, "challenge": challenge}

@app.post("/api/webauthn/authentication/verify")
async def verify_authentication_endpoint(req: AuthenticationVerifyRequest):
    stored = challenge_store.get(req.challenge)
    if not stored:
        raise HTTPException(status_code=400, detail="無效或已過期的challenge")

    del challenge_store[req.challenge]

    try:
        new_sign_count = auth_service.verify_authentication(
            challenge_b64=req.challenge,
            credential_response=req.credential,
            stored_credential={},
        )

        session_token = secrets.token_urlsafe(32)
        session_store[session_token] = {
            "userId": stored["userId"],
            "authenticatedAt": __import__("datetime").datetime.utcnow().isoformat(),
        }

        return {
            "success": True,
            "sessionToken": session_token,
        }
    except Exception as e:
        raise HTTPException(status_code=401, detail=str(e))

@app.get("/api/webauthn/credentials")
async def list_credentials(userId: str):
    return {"credentials": []}

@app.post("/api/webauthn/credentials/delete")
async def delete_credential(request: Request):
    data = await request.json()
    return {"success": True}

@app.post("/api/webauthn/credentials/rename")
async def rename_credential(request: Request):
    data = await request.json()
    return {"success": True}

客戶端完整整合

class WebAuthnClient {
  constructor(baseUrl = "") {
    this.baseUrl = baseUrl;
  }

  async isSupported() {
    if (!window.PublicKeyCredential) return false;
    return PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
  }

  async register(userId, username, displayName) {
    const existingCreds = await this._getExistingCredentials(userId);

    const { options, challenge } = await this._fetch("/api/webauthn/registration/options", {
      userId,
      username,
      displayName,
      excludeCredentials: existingCreds,
    });

    try {
      const credential = await navigator.credentials.create({
        publicKey: this._decodeRegistrationOptions(options),
      });

      const result = await this._fetch("/api/webauthn/registration/verify", {
        challenge,
        credential: this._encodeCredential(credential),
      });

      return { success: true, credentialId: result.credentialId };
    } catch (error) {
      return { success: false, error: error.name, message: error.message };
    }
  }

  async authenticate(userId = null) {
    const { options, challenge } = await this._fetch(
      "/api/webauthn/authentication/options",
      { userId, conditionalUI: false }
    );

    try {
      const assertion = await navigator.credentials.get({
        publicKey: this._decodeAuthenticationOptions(options),
      });

      const result = await this._fetch("/api/webauthn/authentication/verify", {
        challenge,
        credential: this._encodeAssertion(assertion),
      });

      return { success: true, sessionToken: result.sessionToken };
    } catch (error) {
      return { success: false, error: error.name, message: error.message };
    }
  }

  async listCredentials(userId) {
    const response = await fetch(
      `${this.baseUrl}/api/webauthn/credentials?userId=${userId}`
    );
    return response.json();
  }

  async deleteCredential(userId, credentialId) {
    return this._fetch("/api/webauthn/credentials/delete", {
      userId,
      credentialId,
    });
  }

  async renameCredential(userId, credentialId, newName) {
    return this._fetch("/api/webauthn/credentials/rename", {
      userId,
      credentialId,
      newName,
    });
  }

  _decodeRegistrationOptions(options) {
    return {
      ...options,
      challenge: base64urlDecode(options.challenge),
      user: {
        ...options.user,
        id: base64urlDecode(options.user.id),
      },
      excludeCredentials: (options.excludeCredentials || []).map((ec) => ({
        ...ec,
        id: base64urlDecode(ec.id),
      })),
    };
  }

  _decodeAuthenticationOptions(options) {
    return {
      ...options,
      challenge: base64urlDecode(options.challenge),
      allowCredentials: (options.allowCredentials || []).map((ac) => ({
        ...ac,
        id: base64urlDecode(ac.id),
      })),
    };
  }

  _encodeCredential(credential) {
    return {
      id: credential.id,
      rawId: base64urlEncode(credential.rawId),
      type: credential.type,
      response: {
        attestationObject: base64urlEncode(credential.response.attestationObject),
        clientDataJSON: base64urlEncode(credential.response.clientDataJSON),
      },
    };
  }

  _encodeAssertion(assertion) {
    return {
      id: assertion.id,
      rawId: base64urlEncode(assertion.rawId),
      type: assertion.type,
      response: {
        authenticatorData: base64urlEncode(assertion.response.authenticatorData),
        clientDataJSON: base64urlEncode(assertion.response.clientDataJSON),
        signature: base64urlEncode(assertion.response.signature),
        userHandle: assertion.response.userHandle
          ? base64urlEncode(assertion.response.userHandle)
          : null,
      },
    };
  }

  async _fetch(path, body) {
    const response = await fetch(`${this.baseUrl}${path}`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(body),
    });

    if (!response.ok) {
      const error = await response.json();
      throw new Error(error.detail || "請求失敗");
    }

    return response.json();
  }

  async _getExistingCredentials(userId) {
    try {
      const { credentials } = await this.listCredentials(userId);
      return credentials.map((c) => ({ id: c.id }));
    } catch {
      return [];
    }
  }
}

const webauthn = new WebAuthnClient();

5大避坑指南

坑1:challenge未校驗或重複使用

❌ 錯誤:

CHALLENGE = "fixed-challenge-value-12345"

def verify_registration(credential_response):
    verification = verify_registration_response(
        credential=credential_response,
        expected_challenge=CHALLENGE,
        expected_origin=ORIGIN,
        expected_rp_id=RP_ID,
    )
const CHALLENGE = "fixed-challenge-value-12345";
const options = { challenge: CHALLENGE, ... };

✅ 正確:

import secrets

def generate_challenge() -> str:
    challenge = secrets.token_bytes(32)
    challenge_b64 = base64.urlsafe_b64encode(challenge).rstrip(b"=").decode()
    challenge_store[challenge_b64] = {
        "createdAt": datetime.utcnow(),
        "used": False,
    }
    return challenge_b64

def verify_challenge(challenge_b64: str) -> bool:
    stored = challenge_store.get(challenge_b64)
    if not stored or stored["used"]:
        return False
    age = (datetime.utcnow() - stored["createdAt"]).total_seconds()
    if age > 300:
        del challenge_store[challenge_b64]
        return False
    stored["used"] = True
    return True
// 客戶端永遠從服務端取得challenge,不自行產生
const { options, challenge } = await fetch("/api/webauthn/registration/options", {
  method: "POST",
  body: JSON.stringify({ userId, username, displayName }),
}).then(r => r.json());

坑2:origin校驗缺失

❌ 錯誤:

def verify_registration(credential_response, challenge):
    verification = verify_registration_response(
        credential=credential_response,
        expected_challenge=challenge,
        expected_origin="*",  # 不校驗origin
        expected_rp_id=RP_ID,
    )
// 客戶端無origin校驗意識,直接傳送

✅ 正確:

ALLOWED_ORIGINS = {
    "https://yourapp.com",
    "https://www.yourapp.com",
}

def verify_registration(credential_response, challenge):
    client_data = json.loads(
        base64.urlsafe_b64decode(
            credential_response["response"]["clientDataJSON"] + "=="
        )
    )
    origin = client_data.get("origin")

    if origin not in ALLOWED_ORIGINS:
        raise ValueError(f"不允許的origin: {origin}")

    verification = verify_registration_response(
        credential=credential_response,
        expected_challenge=challenge,
        expected_origin=origin,
        expected_rp_id=RP_ID,
    )
// 確保頁面執行在正確的origin下
if (window.location.origin !== "https://yourapp.com") {
  console.error("WebAuthn只能在正確的origin下使用");
}

坑3:sign_count未校驗導致複製攻擊

❌ 錯誤:

def verify_authentication(credential_response, stored_credential):
    verification = verify_authentication_response(
        credential=credential_response,
        expected_challenge=challenge,
        expected_origin=ORIGIN,
        expected_rp_id=RP_ID,
        credential_public_key=stored_credential["public_key"],
        credential_current_sign_count=0,  # 始終傳0
    )
    # 忽略回傳的sign_count

✅ 正確:

def verify_authentication(credential_response, stored_credential):
    verification = verify_authentication_response(
        credential=credential_response,
        expected_challenge=challenge,
        expected_origin=ORIGIN,
        expected_rp_id=RP_ID,
        credential_public_key=stored_credential["public_key"],
        credential_current_sign_count=stored_credential["sign_count"],
    )

    if verification.new_sign_count <= stored_credential["sign_count"]:
        log_security_event(
            "possible_credential_clone",
            credential_id=stored_credential["credential_id"],
            stored_count=stored_credential["sign_count"],
            received_count=verification.new_sign_count,
        )
        raise ValueError("可能的憑證複製攻擊")

    stored_credential["sign_count"] = verification.new_sign_count

坑4:residentKey設定錯誤導致Passkey無法同步

❌ 錯誤:

options = {
    "authenticatorSelection": {
        "authenticatorAttachment": "platform",
        "requireResidentKey": False,  # 錯誤:Passkey需要resident key
        "residentKey": "discouraged",
        "userVerification": "discouraged",
    },
}
const options = {
  authenticatorSelection: {
    authenticatorAttachment: "platform",
    requireResidentKey: false,
    residentKey: "discouraged",
    userVerification: "discouraged",
  },
};

✅ 正確:

options = {
    "authenticatorSelection": {
        "authenticatorAttachment": "platform",
        "requireResidentKey": True,
        "residentKey": "required",  # Passkey必須設定為required
        "userVerification": "required",  # 確保生物識別驗證
    },
}
const options = {
  authenticatorSelection: {
    authenticatorAttachment: "platform",
    requireResidentKey: true,
    residentKey: "required",
    userVerification: "required",
  },
};

坑5:刪除最後一個憑證導致帳戶鎖死

❌ 錯誤:

def delete_credential(user_id: str, credential_id: str):
    cred = db.query(Credential).filter_by(id=credential_id).first()
    db.delete(cred)
    db.commit()
    return {"success": True}

✅ 正確:

def delete_credential(user_id: str, credential_id: str):
    remaining = db.query(Credential).filter_by(user_id=user_id).count()

    if remaining <= 1:
        has_password = db.query(User).filter_by(id=user_id).first().password_hash
        if not has_password:
            raise ValueError(
                "這是您最後一個Passkey且未設定密碼,"
                "刪除後將無法登入。請先註冊備用Passkey或設定密碼。"
            )

    cred = db.query(Credential).filter_by(id=credential_id).first()
    db.delete(cred)
    db.commit()
    return {"success": True}

錯誤排查表

錯誤訊息 原因 解決方案
DOMException: The operation is insecure 頁面非HTTPS或localhost 確保使用HTTPS或localhost開發環境
DOMException: The operation was cancelled 使用者取消或逾時 捕獲NotAllowedError,提供重試選項
InvalidStateError 裝置已註冊該RP的憑證 使用excludeCredentials排除已註冊憑證
SecurityError: The rpId is not valid rpId與目前網域不匹配 確認rpId為有效網域且與頁面origin一致
TypeError: Credentials container is empty 無匹配的allowCredentials 檢查credential ID編碼是否正確
DOMException: A requested feature is not supported 瀏覽器不支援residentKey 降級使用residentKey: "preferred"
Verification failed: signature mismatch 公鑰或簽名資料損壞 檢查base64url編解碼,確認COSE key格式
Verification failed: challenge mismatch challenge編碼不一致 確保服務端和客戶端使用相同的base64url編解碼
NetworkError: authentication cancelled 認證器通訊中斷 重試,檢查USB/NFC連線(安全金鑰場景)
ConstraintError: request has too many params excludeCredentials過多 限制排除列表最多10個憑證

進階最佳化

1. Challenge儲存最佳化(Redis + TTL)

import redis
import json

redis_client = redis.Redis(host="localhost", port=6379, db=0)

class RedisChallengeStore:
    CHALLENGE_TTL = 300  # 5分鐘過期

    def store(self, challenge: str, data: dict) -> None:
        key = f"webauthn:challenge:{challenge}"
        redis_client.setex(key, self.CHALLENGE_TTL, json.dumps(data))

    def retrieve(self, challenge: str) -> dict | None:
        key = f"webauthn:challenge:{challenge}"
        data = redis_client.get(key)
        if data:
            redis_client.delete(key)
            return json.loads(data)
        return None

    def exists(self, challenge: str) -> bool:
        return redis_client.exists(f"webauthn:challenge:{challenge}") > 0

2. 憑證傳輸層最佳化

def store_credential_with_transports(
    user_id: str,
    credential_id: str,
    public_key: str,
    sign_count: int,
    transports: list[str],
) -> None:
    credential = PasskeyCredential(
        credential_id=credential_id,
        user_id=user_id,
        public_key=public_key,
        sign_count=sign_count,
        transports=transports,
        device_type=_classify_device(transports),
        created_at=datetime.utcnow(),
    )
    db.add(credential)
    db.commit()

def _classify_device(transports: list[str]) -> str:
    if "internal" in transports:
        return "platform"
    if "usb" in transports and "nfc" in transports:
        return "security_key_multi"
    if "usb" in transports:
        return "security_key_usb"
    if "nfc" in transports:
        return "security_key_nfc"
    if "ble" in transports:
        return "security_key_ble"
    return "unknown"

3. 認證稽核日誌

from dataclasses import dataclass
from datetime import datetime

@dataclass
class AuthAuditLog:
    id: str
    user_id: str
    event_type: str
    credential_id: str
    ip_address: str
    user_agent: str
    success: bool
    error_message: str | None
    created_at: datetime

class AuthAuditService:
    def log_registration(
        self,
        user_id: str,
        credential_id: str,
        success: bool,
        request,
        error: str | None = None,
    ) -> None:
        log = AuthAuditLog(
            id=secrets.token_urlsafe(16),
            user_id=user_id,
            event_type="passkey_registration",
            credential_id=credential_id,
            ip_address=request.client.host,
            user_agent=request.headers.get("user-agent", ""),
            success=success,
            error_message=error,
            created_at=datetime.utcnow(),
        )
        db.add(log)
        db.commit()

    def log_authentication(
        self,
        user_id: str,
        credential_id: str,
        success: bool,
        request,
        error: str | None = None,
    ) -> None:
        log = AuthAuditLog(
            id=secrets.token_urlsafe(16),
            user_id=user_id,
            event_type="passkey_authentication",
            credential_id=credential_id,
            ip_address=request.client.host,
            user_agent=request.headers.get("user-agent", ""),
            success=success,
            error_message=error,
            created_at=datetime.utcnow(),
        )
        db.add(log)
        db.commit()

    def detect_anomaly(self, user_id: str, window_minutes: int = 15) -> bool:
        cutoff = datetime.utcnow() - timedelta(minutes=window_minutes)
        recent_failures = (
            db.query(AuthAuditLog)
            .filter(
                AuthAuditLog.user_id == user_id,
                AuthAuditLog.success == False,
                AuthAuditLog.created_at >= cutoff,
            )
            .count()
        )
        return recent_failures >= 5

認證方式對比

特性 WebAuthn/Passkey TOTP SMS OTP Magic Link OAuth2
防釣魚 ✅ 網域綁定 ❌ 可被釣魚 ❌ 可被釣魚 ⚠️ 部分防護 ⚠️ 依賴IdP
使用者體驗 ✅ 一鍵生物識別 ⚠️ 需輸入6位碼 ⚠️ 等待簡訊 ⚠️ 需查信箱 ✅ 一鍵跳轉
裝置要求 ⚠️ 需支援WebAuthn ✅ 任何手機 ✅ 任何手機 ✅ 任何信箱 ✅ 任何瀏覽器
離線可用 ✅ 本地驗證 ✅ 離線產生 ❌ 需網路 ❌ 需網路 ❌ 需網路
多裝置同步 ✅ 雲端同步 ❌ 需手動遷移 ✅ 手機號不變 ✉️ 信箱可登入 ✅ 帳號通用
實作成本 ⚠️ 中等 ✅ 低 ⚠️ 需簡訊費用 ✅ 低 ⚠️ 需整合IdP
安全等級 🏆 最高 ⭐ 高 ⭐ 中 ⭐ 中 ⭐ 高
標準化 ✅ W3C/FIDO ✅ RFC 6238 ❌ 無統一標準 ❌ 無統一標準 ✅ RFC 6749

總結:WebAuthn/FIDO2不是「錦上添花」的可選功能,而是2026年身份認證的必選項。6種生產模式涵蓋了從註冊到認證、從憑證管理到多裝置支援、從回退策略到完整服務的全鏈路。記住三個關鍵:challenge必須真隨機單次使用、residentKey必須設為required、永遠保留至少一個恢復通道。Passkey是密碼的終結者,但前提是你正確實作了它。


推薦工具

  • Hash工具 - 產生challenge的SHA-256雜湊,用於除錯WebAuthn challenge
  • Base64編解碼 - 解碼WebAuthn中的base64url編碼資料,除錯credential資料
  • JWT解碼 - 解碼認證後的session token,驗證WebAuthn認證結果

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

#WebAuthn#FIDO2#Passkey#无密码认证#生物识别#2026#安全