WebAuthn FIDO2 Implementation: 6 Production Patterns from Registration to Passwordless Auth

安全

Passwords Are Dead, Passkeys Are the Future

In 2026, password breaches continue at hundreds of millions per month. Phishing attacks bypass traditional MFA, SMS OTP falls to SIM swapping, and TOTP users blindly approve requests due to "MFA fatigue." Passkeys based on the FIDO2/WebAuthn standard eliminate passwords entirely through public-key cryptography + device biometrics — Apple, Google, and Microsoft offer full cross-ecosystem interoperability, with browser coverage exceeding 96%.

This article delivers 6 production-grade patterns: from passkey registration to authentication flows, credential management to multi-device support, fallback strategies to a complete Python + JavaScript production service — every snippet is runnable.


WebAuthn/FIDO2 Core Concepts

Concept Description
WebAuthn W3C standard, browser-provided navigator.credentials API defining registration and authentication JavaScript interfaces
FIDO2 FIDO Alliance standard including CTAP2 protocol, governing authenticator-server communication
Passkey Syncable FIDO2 credential stored in iCloud Keychain/Google Password Manager
Authenticator Authentication device — platform (TouchID/FaceID/Windows Hello) or roaming (YubiKey)
Relying Party (RP) The relying party, i.e., your website/server, identified by rpID
Credential Public-key cryptographic key pair; private key never leaves the authenticator's secure chip
Attestation Proof the authenticator provides to the RP during registration about its legitimacy (optional, can be "none")
Assertion Signed proof returned by the authenticator during authentication after signing the challenge

Problem Analysis: 5 Challenges of WebAuthn in Production

  1. Complex registration flow: Server must generate correct PublicKeyCredentialCreationOptions — COSE algorithm identifiers, authenticatorSelection parameters are error-prone
  2. Authentication security: Challenge must be truly random and single-use, signature verification requires CBOR/COSE handling — missing any check creates a security hole
  3. Credential lifecycle management: Users may register multiple passkeys; need list, delete, rename support, plus recovery when all devices are lost
  4. Cross-platform & multi-device: iCloud Passkeys and Google Passkeys don't interoperate; enterprise scenarios need security keys (YubiKey); Conditional UI integration is complex
  5. Fallback & degradation: Legacy devices don't support WebAuthn; password fallback is mandatory; hybrid auth flow UX design is challenging

6 Production Patterns

Pattern 1: Passkey Registration Flow

Server (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,
        }

Client (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 registered successfully!");
    }
  } catch (error) {
    if (error.name === "InvalidStateError") {
      showToast("This device already has a passkey registered");
    } else if (error.name === "NotAllowedError") {
      showToast("Registration was cancelled");
    } else {
      showToast(`Registration failed: ${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: Authentication Flow with WebAuthn API

Server:

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

Client:

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("Authentication cancelled");
    } else {
      showToast(`Authentication failed: ${error.message}`);
    }
  }
}

Conditional UI (Autofill) Mode:

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,
    });
    // Process authentication result, same as above
  } catch (error) {
    // Conditional UI: user not selecting a passkey is normal, handle silently
  }
}

// Auto-setup on page load
document.addEventListener("DOMContentLoaded", () => {
  const usernameInput = document.getElementById("username");
  if (usernameInput) {
    usernameInput.setAttribute("autocomplete", "username webauthn");
    setupConditionalUI();
  }
});

Pattern 3: Credential Management (List, Delete, Update)

Server:

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("Cannot delete the last passkey. Register a backup credential first.")

        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()

Client:

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("Are you sure you want to delete this passkey? This cannot be undone.");
    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">
            Created ${new Date(cred.createdAt).toLocaleDateString()}
            ${cred.lastUsedAt ? `· Last used ${new Date(cred.lastUsedAt).toLocaleDateString()}` : ""}
          </span>
        </div>
        <div class="passkey-actions">
          <button onclick="passkeyManager.renameCredential('${cred.id}', prompt('New name:', '${cred.name}'))">
            Rename
          </button>
          <button onclick="passkeyManager.deleteCredential('${cred.id}')">
            Delete
          </button>
        </div>
      </div>
    `
      )
      .join("");
  }
}

Pattern 4: Multi-Device and Cross-Platform Support

Server:

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

Client:

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}')">
        Register Passkey (Device Biometrics)
      </button>
    `;
  }

  container.innerHTML += `
    <button onclick="registerSecurityKey('${userId}', '${username}', '${displayName}')">
      Register Security Key (YubiKey, etc.)
    </button>
  `;

  if (!support.supported) {
    container.innerHTML = `
      <p>Your browser does not support WebAuthn. Please use password login.</p>
    `;
  }
}

Pattern 5: Fallback Strategy (Password + Passkey Hybrid)

Server:

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": "Upgrade your account: register a passkey for secure passwordless login",
        }

Client:

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="Username" required />

      <div id="passkey-section" style="display:none">
        <button type="button" id="passkey-login-btn">
          Sign in with Passkey
        </button>
        <p class="divider-text">or use password</p>
      </div>

      <div id="password-section">
        <input type="password" id="password" name="password"
          autocomplete="current-password"
          placeholder="Password" />
        <button type="submit">Sign In</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 {
      // User didn't select a passkey, handle silently
    }
  }

  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(
      "Your device supports passkeys. Would you like to register now for secure passwordless login?"
    );
    if (upgrade) {
      registerPlatformPasskey(userId);
    } else {
      window.location.href = "/dashboard";
    }
  }
}

Pattern 6: Production Auth Service with Python (py_webauthn) + JavaScript

Server (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="Invalid or expired challenge")

    del challenge_store[req.challenge]

    try:
        result = registration_service.verify_registration(
            challenge_b64=req.challenge,
            credential_response=req.credential,
            expected_origin=ORIGIN,
        )
        # Save credential to database
        # save_credential(stored["userId"], result)

        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:
        # Fetch user credentials from database
        # allow_credentials = get_user_credentials(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="Invalid or expired challenge")

    del challenge_store[req.challenge]

    # Fetch credential from database
    # stored_credential = get_credential(req.credential.id)

    try:
        new_sign_count = auth_service.verify_authentication(
            challenge_b64=req.challenge,
            credential_response=req.credential,
            stored_credential={},  # Replace with actual 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):
    # Fetch from database
    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}

Complete Client Integration:

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 || "Request failed");
    }

    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 Pitfall Guide

Pitfall 1: Challenge Not Validated or Reused

❌ Wrong:

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, ... };

✅ Correct:

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
// Always fetch challenge from server, never generate client-side
const { options, challenge } = await fetch("/api/webauthn/registration/options", {
  method: "POST",
  body: JSON.stringify({ userId, username, displayName }),
}).then(r => r.json());

Pitfall 2: Missing Origin Validation

❌ Wrong:

def verify_registration(credential_response, challenge):
    verification = verify_registration_response(
        credential=credential_response,
        expected_challenge=challenge,
        expected_origin="*",  # No origin validation
        expected_rp_id=RP_ID,
    )
// Client has no origin validation awareness

✅ Correct:

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"Disallowed origin: {origin}")

    verification = verify_registration_response(
        credential=credential_response,
        expected_challenge=challenge,
        expected_origin=origin,
        expected_rp_id=RP_ID,
    )
// Ensure the page runs under the correct origin
if (window.location.origin !== "https://yourapp.com") {
  console.error("WebAuthn can only be used under the correct origin");
}

Pitfall 3: Sign Count Not Validated (Clone Attack)

❌ Wrong:

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,  # Always pass 0
    )
    # Ignore returned sign_count

✅ Correct:

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"]:
        # Possible cloned credential, log security event
        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("Possible credential clone attack detected")

    stored_credential["sign_count"] = verification.new_sign_count

Pitfall 4: Wrong residentKey Configuration Prevents Passkey Sync

❌ Wrong:

options = {
    "authenticatorSelection": {
        "authenticatorAttachment": "platform",
        "requireResidentKey": False,  # Wrong: Passkeys need resident key
        "residentKey": "discouraged",
        "userVerification": "discouraged",
    },
}
const options = {
  authenticatorSelection: {
    authenticatorAttachment: "platform",
    requireResidentKey: false,
    residentKey: "discouraged",
    userVerification: "discouraged",
  },
};

✅ Correct:

options = {
    "authenticatorSelection": {
        "authenticatorAttachment": "platform",
        "requireResidentKey": True,
        "residentKey": "required",  # Passkeys must be set to required
        "userVerification": "required",  # Ensure biometric verification
    },
}
const options = {
  authenticatorSelection: {
    authenticatorAttachment: "platform",
    requireResidentKey: true,
    residentKey: "required",
    userVerification: "required",
  },
};

Pitfall 5: Deleting the Last Credential Locks Out the Account

❌ Wrong:

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}

✅ Correct:

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(
                "This is your last passkey and no password is set. "
                "Deleting it will lock you out. Register a backup passkey or set a password first."
            )

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

Error Troubleshooting Table

Error Cause Solution
DOMException: The operation is insecure Page not on HTTPS or localhost Ensure HTTPS or localhost development environment
DOMException: The operation was cancelled User cancelled or timeout Catch NotAllowedError, provide retry option
InvalidStateError Device already has a credential for this RP Use excludeCredentials to exclude registered credentials
SecurityError: The rpId is not valid rpId doesn't match current domain Confirm rpId is a valid domain matching the page origin
TypeError: Credentials container is empty No matching allowCredentials Check credential ID encoding is correct
DOMException: A requested feature is not supported Browser doesn't support residentKey Degrade to residentKey: "preferred"
Verification failed: signature mismatch Public key or signature data corrupted Check base64url encoding/decoding, confirm COSE key format
Verification failed: challenge mismatch Challenge encoding inconsistency Ensure server and client use the same base64url encoding
NetworkError: authentication cancelled Authenticator communication interrupted Retry, check USB/NFC connection (security key scenario)
ConstraintError: request has too many params Too many excludeCredentials Limit exclusion list to max 10 credentials

Advanced Optimization

1. Challenge Storage Optimization (Redis + TTL)

import redis
import json

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

class RedisChallengeStore:
    CHALLENGE_TTL = 300  # 5 minutes

    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. Credential Transport Layer Optimization

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. Authentication Audit Logging

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

Authentication Method Comparison

Feature WebAuthn/Passkey TOTP SMS OTP Magic Link OAuth2
Anti-phishing ✅ Domain-bound ❌ Phishable ❌ Phishable ⚠️ Partial ⚠️ IdP-dependent
User experience ✅ One-tap biometric ⚠️ Enter 6-digit code ⚠️ Wait for SMS ⚠️ Check email ✅ One-click redirect
Device requirement ⚠️ WebAuthn support ✅ Any phone ✅ Any phone ✅ Any email ✅ Any browser
Offline capable ✅ Local verification ✅ Offline generation ❌ Requires network ❌ Requires network ❌ Requires network
Multi-device sync ✅ Cloud sync ❌ Manual migration ✅ Same phone number ✅ Email accessible ✅ Universal account
Implementation cost ⚠️ Medium ✅ Low ⚠️ SMS cost ✅ Low ⚠️ IdP integration
Security level 🏆 Highest ⭐ High ⭐ Medium ⭐ Medium ⭐ High
Standardization ✅ W3C/FIDO ✅ RFC 6238 ❌ No unified standard ❌ No unified standard ✅ RFC 6749

Summary: WebAuthn/FIDO2 is not an optional "nice-to-have" — it's the mandatory standard for authentication in 2026. The 6 production patterns cover the full pipeline from registration to authentication, credential management to multi-device support, fallback strategies to a complete service. Remember three key principles: challenges must be truly random and single-use, residentKey must be set to required, and always preserve at least one recovery channel. Passkeys are the password killer — but only if you implement them correctly.


  • Hash Tool - Generate SHA-256 hashes of challenges for debugging WebAuthn challenges
  • Base64 Encode/Decode - Decode base64url-encoded data in WebAuthn, debug credential data
  • JWT Decode - Decode session tokens after authentication, verify WebAuthn authentication results

Try these browser-local tools — no sign-up required →

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