tialProvider'] = 'none';
if (/iPad|iPhone|iPod/.test(ua)) {
platform = 'ios';
provider = 'apple';
} else if (/Android/.test(ua)) {
platform = 'android';
provider = 'google';
} else if (/Mac OS X/.test(ua)) {
platform = 'macos';
provider = 'apple';
} else if (/Windows/.test(ua)) {
platform = 'windows';
provider = 'microsoft';
}
return {
platform,
webauthnSupported: isWebAuthn,
conditionalUIAvailable: isConditional,
credentialProvider: provider
};
}
}
### Phase 2: Conditional Credential Creation
Flat prompts fail because they ignore user intent. The orchestrator should only trigger passkey creation when the device supports it and the user is in a high-intent state (e.g., successful password login, account setup, or security settings visit).
```typescript
interface PasskeyCreationRequest {
userId: string;
displayName: string;
challenge: Uint8Array;
rpId: string;
}
class CredentialOrchestrator {
private telemetry: CeremonyTelemetry;
constructor(telemetry: CeremonyTelemetry) {
this.telemetry = telemetry;
}
async initiateCreation(req: PasskeyCreationRequest): Promise<boolean> {
const device = DeviceClassifier.detect();
if (!device.webauthnSupported) {
this.telemetry.logAbort('unsupported_platform');
return false;
}
try {
const credential = await navigator.credentials.create({
publicKey: {
rp: { id: req.rpId, name: 'Application' },
user: {
id: new TextEncoder().encode(req.userId),
name: req.displayName,
displayName: req.displayName
},
challenge: req.challenge,
pubKeyCredParams: [
{ alg: -7, type: 'public-key' },
{ alg: -257, type: 'public-key' }
],
authenticatorSelection: {
authenticatorAttachment: device.platform === 'ios' ? 'platform' : 'cross-platform',
userVerification: 'preferred',
requireResidentKey: false
},
timeout: 60000
}
}) as PublicKeyCredential;
this.telemetry.logSuccess('creation', credential.id);
return true;
} catch (err) {
this.telemetry.logFailure('creation', err instanceof Error ? err.message : 'unknown');
return false;
}
}
}
Phase 3: Identifier-First Recovery
Users lose devices or clear credential stores. A passwordless system that lacks recovery defaults to account lockout or password fallback. The orchestrator must implement identifier-first flows that verify ownership via email/SMS before allowing credential recreation.
interface RecoveryContext {
identifier: string;
verificationToken: string;
}
class RecoveryHandler {
async verifyOwnership(ctx: RecoveryContext): Promise<boolean> {
const response = await fetch('/api/auth/recovery/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(ctx)
});
if (!response.ok) return false;
const data = await response.json();
return data.verified === true;
}
async rebuildCredential(ctx: RecoveryContext, challenge: Uint8Array): Promise<string | null> {
const isVerified = await this.verifyOwnership(ctx);
if (!isVerified) return null;
const device = DeviceClassifier.detect();
if (!device.webauthnSupported) return null;
try {
const cred = await navigator.credentials.create({
publicKey: {
rp: { id: window.location.hostname, name: 'Application' },
user: { id: new TextEncoder().encode(ctx.identifier), name: ctx.identifier, displayName: ctx.identifier },
challenge,
pubKeyCredParams: [{ alg: -7, type: 'public-key' }],
authenticatorSelection: { authenticatorAttachment: 'platform', userVerification: 'required' },
timeout: 45000
}
}) as PublicKeyCredential;
return cred.id;
} catch {
return null;
}
}
}
Phase 4: Client-Side Ceremony Telemetry
Backend logs only capture successful authentication events. They miss pre-identifier drop-offs, autofill conflicts, overlay dismissals, and conditional UI timeouts. The orchestrator must instrument the entire client journey.
interface TelemetryEvent {
flow: 'enrollment' | 'login' | 'recovery';
stage: 'init' | 'prompt' | 'interaction' | 'success' | 'abort' | 'failure';
device: string;
durationMs: number;
metadata?: Record<string, unknown>;
}
class CeremonyTelemetry {
private queue: TelemetryEvent[] = [];
private flushInterval: number;
constructor(flushMs = 5000) {
this.flushInterval = flushMs;
setInterval(() => this.flush(), this.flushInterval);
}
log(flow: TelemetryEvent['flow'], stage: TelemetryEvent['stage'], metadata?: Record<string, unknown>) {
const event: TelemetryEvent = {
flow,
stage,
device: DeviceClassifier.detect().platform,
durationMs: performance.now(),
metadata
};
this.queue.push(event);
}
private async flush() {
if (this.queue.length === 0) return;
const batch = [...this.queue];
this.queue = [];
try {
await fetch('/api/telemetry/auth-ceremony', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(batch)
});
} catch {
this.queue.push(...batch);
}
}
}
Architecture Rationale
The separation of orchestration from CIAM is intentional. Identity providers optimize for security compliance, token lifecycle, and multi-tenant policy. They are not designed to handle real-time UX branching or client-side telemetry. By extracting device detection, conditional creation, recovery routing, and telemetry into a dedicated layer, teams gain:
- Independent iteration on conversion metrics without modifying identity policy
- Granular visibility into pre-identifier drop-offs
- Platform-specific fallback routing without backend coupling
- Reduced blast radius when OS/browser WebAuthn implementations change
Pitfall Guide
1. Backend-Only Observability Trap
Explanation: Teams rely on CIAM logs, APM traces, or SIEM data to measure passkey success. These systems only record events after the identifier is submitted. They miss autofill conflicts, overlay dismissals, conditional UI timeouts, and browser permission denials.
Fix: Instrument client-side telemetry that captures every ceremony stage. Track init, prompt, interaction, success, abort, and failure events with device context and duration metrics.
2. Flat Prompt Strategy
Explanation: Displaying a generic passkey prompt to all users ignores device capability and user intent. Windows users see credential provider conflicts, while iOS users get redundant overlays. Conversion drops because the prompt feels irrelevant or confusing.
Fix: Implement conditional prompting. Only show passkey creation after successful authentication, during account setup, or when device capability detection confirms native support. Use identifier-first flows to qualify intent.
3. Ignoring Windows Credential Provider Gaps
Explanation: Windows routes WebAuthn through multiple credential providers (Microsoft Account, Hello, third-party security keys). First-try success rates sit at 25β39%. Teams assume WebAuthn support equals reliable passkey creation, leading to silent failures.
Fix: Detect Windows environments and route to cross-platform authenticator selection. Provide explicit fallback to email/SMS verification when platform attachment fails. Log provider routing attempts separately.
4. Skipping Identifier-First Recovery
Explanation: Passwordless systems without recovery paths force users into account lockout or password fallback when devices are lost or credential stores are cleared. This destroys trust and reverts adoption metrics.
Fix: Implement identifier-first recovery. Verify ownership via out-of-band channels before allowing credential recreation. Cache recovery tokens securely and enforce single-use expiration.
5. Underestimating Maintenance TCO
Explanation: Teams treat passkey rollout as a one-time integration. In reality, OS updates, browser changes, and credential provider shifts require continuous testing and fallback adjustment. Internal builds cost 25β30 FTE-months initially and 1.5 FTE annually.
Fix: Budget for ongoing orchestration maintenance. Automate device capability testing across major OS/browser combinations. Maintain a fallback matrix that can be toggled without redeploying the identity layer.
6. Treating Passkeys as a Single Feature
Explanation: Engineering teams bundle passkey creation, login, and recovery into a single implementation. This creates tight coupling and makes it impossible to optimize individual conversion funnels.
Fix: Decouple ceremonies. Build separate handlers for enrollment, authentication, and recovery. Route each through device-specific logic and independent telemetry pipelines.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| <100k MAU, limited engineering bandwidth | Use CIAM-native passkey UI with basic telemetry | Lower initial overhead; sufficient for early validation | Low upfront, moderate long-term if adoption stalls |
| 100kβ500k MAU, conversion-focused | Build lightweight orchestration layer with conditional prompts | Balances control and maintenance; targets 20β40% adoption | Moderate upfront (8β12 FTE-months), low ongoing |
| 500k+ MAU, majority passkey target | Full orchestration with device routing, recovery, and telemetry | Required to break 5β10% ceiling; handles fragmentation | High upfront (25β30 FTE-months), 1.5 FTE/year maintenance |
| Legacy app with heavy password reliance | Identifier-first recovery + post-login nudge | Gradual migration without breaking existing sessions | Low risk, slow adoption curve |
Configuration Template
// orchestrator.config.ts
export const PasskeyOrchestratorConfig = {
telemetry: {
enabled: true,
flushIntervalMs: 5000,
endpoint: '/api/telemetry/auth-ceremony',
stages: ['init', 'prompt', 'interaction', 'success', 'abort', 'failure'] as const
},
deviceRouting: {
ios: { attachment: 'platform', verification: 'preferred', fallback: 'none' },
android: { attachment: 'cross-platform', verification: 'required', fallback: 'email' },
macos: { attachment: 'platform', verification: 'preferred', fallback: 'sms' },
windows: { attachment: 'cross-platform', verification: 'required', fallback: 'email' },
linux: { attachment: 'cross-platform', verification: 'required', fallback: 'email' }
},
recovery: {
enabled: true,
tokenExpiryMinutes: 15,
maxAttempts: 3,
channels: ['email', 'sms'] as const
},
fallback: {
password: { enabled: true, threshold: 0.7 },
smsOtp: { enabled: true, threshold: 0.5 },
emailOtp: { enabled: true, threshold: 0.3 }
}
};
Quick Start Guide
- Scaffold the orchestrator: Copy the
DeviceClassifier, CredentialOrchestrator, RecoveryHandler, and CeremonyTelemetry classes into your frontend authentication module.
- Wire telemetry: Initialize
CeremonyTelemetry at application startup and attach log() calls to every WebAuthn promise resolution or rejection.
- Configure device routing: Import
PasskeyOrchestratorConfig and map platform-specific attachment and verification requirements before calling navigator.credentials.create() or get().
- Deploy identifier-first recovery: Add a
/api/auth/recovery/verify endpoint that validates out-of-band tokens and returns a signed challenge for credential recreation.
- Validate with staged rollout: Enable passkey prompts for 10% of traffic, monitor telemetry dashboards for pre-identifier drop-offs, and adjust conditional logic before expanding to 100%.