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
- Complex registration flow: Server must generate correct
PublicKeyCredentialCreationOptions— COSE algorithm identifiers, authenticatorSelection parameters are error-prone - Authentication security: Challenge must be truly random and single-use, signature verification requires CBOR/COSE handling — missing any check creates a security hole
- Credential lifecycle management: Users may register multiple passkeys; need list, delete, rename support, plus recovery when all devices are lost
- Cross-platform & multi-device: iCloud Passkeys and Google Passkeys don't interoperate; enterprise scenarios need security keys (YubiKey); Conditional UI integration is complex
- 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.
Recommended Tools
- 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 →