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大挑戰
- 註冊流程複雜:服務端需產生正確的
PublicKeyCredentialCreationOptions,COSE演算法識別、authenticatorSelection參數設定極易出錯 - 認證流程安全:challenge必須真隨機、單次使用,簽名驗證需處理CBOR/COSE編碼,遺漏任何校驗即產生安全漏洞
- 憑證生命週期管理:使用者可能註冊多個Passkey,需支援列表展示、刪除、重新命名,遺失所有裝置時需恢復機制
- 跨平台與多裝置:iCloud Passkey與Google Passkey不互通,企業場景需支援安全金鑰(YubiKey),條件UI(Conditional UI)整合複雜
- 降級與回退:老舊裝置不支援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是密碼的終結者,但前提是你正確實作了它。
推薦工具
本站提供瀏覽器本地工具,免註冊即可試用 →
#WebAuthn#FIDO2#Passkey#无密码认证#生物识别#2026#安全