Debugging and Observability for Client-Side WebAuthn
Client-side WebAuthn failures are notoriously opaque: the browser deliberately collapses many distinct causes into a single NotAllowedError to avoid leaking whether a credential exists. A dismissed prompt, an expired timeout, a missing user gesture, and a mismatched RP ID can all surface identically. This page is the diagnostic reference that untangles them — mapping each DOMException to the conditions that trigger it, giving reproducible tests for each, and defining the telemetry you need to observe passkey ceremonies in production without leaking credential material. It is the client-side counterpart to the server-focused WebAuthn server debugging reference, and sits under Frontend UX and Conditional Mediation.
Concept Definition and Spec Grounding
The WebAuthn API rejects promises with DOMException instances whose name — not message — is the stable, spec-defined signal. WebAuthn Level 2 §5.1.3–§5.1.4 defines the ceremony algorithms and the privacy principle behind the collapsing: the client platform must not reveal to the RP whether the failure was “no such credential” versus “user declined”, so both map to NotAllowedError. Your diagnosis therefore comes from context you control (was there a gesture? what timeout? what RP ID?) rather than from the exception text.
The exceptions you will actually see:
NotAllowedError— the catch-all: prompt dismissed, timeout elapsed, no user activation, or origin/RP-ID policy blocked the ceremony.AbortError— your ownAbortSignalfired; expected control flow, not a failure.SecurityError—rpIdis not a registrable-suffix match of the origin, or the page is not a secure context.InvalidStateError— a credential already exists forexcludeCredentials(registration), or an overlapping ceremony is in flight.ConstraintError— a required option (typicallyuserVerification: 'required') cannot be satisfied by the available authenticator.NotSupportedError— none of the requestedpubKeyCredParamsalgorithms are supported.
Architecture and Data Flow
The decision tree below is the triage path: start from the exception name, then use the context you logged to reach a root cause and fix.
Implementation Guide
Step 1 — Capture ceremony context, not just the error
Log the context you control before the ceremony so the collapsed NotAllowedError becomes diagnosable.
interface CeremonyContext {
type: 'get' | 'create';
mediation: 'conditional' | 'optional' | 'required';
hadUserGesture: boolean;
timeoutMs: number;
rpId: string;
origin: string;
startedAt: number;
}
function beginCeremony(ctx: Omit<CeremonyContext, 'startedAt'>): CeremonyContext {
return { ...ctx, startedAt: performance.now() };
}
Step 2 — Classify the exception with context
Combine err.name with the elapsed time to distinguish a timeout from a fast dismissal.
function classify(err: unknown, ctx: CeremonyContext): string {
const name = (err as DOMException)?.name ?? 'Unknown';
const elapsed = performance.now() - ctx.startedAt;
if (name === 'AbortError') return 'aborted';
if (name === 'NotAllowedError') {
if (!ctx.hadUserGesture && ctx.mediation !== 'conditional') return 'missing-user-gesture';
if (elapsed >= ctx.timeoutMs - 250) return 'timeout';
return 'user-dismissed';
}
if (name === 'SecurityError') return 'rpid-or-origin-misconfig';
if (name === 'InvalidStateError') return ctx.type === 'create' ? 'already-registered' : 'overlapping-ceremony';
if (name === 'ConstraintError') return 'uv-unsatisfiable';
if (name === 'NotSupportedError') return 'alg-unsupported';
return `unclassified:${name}`;
}
Step 3 — Reproduce SecurityError deterministically
SecurityError is always a config bug and always reproducible. Compare rpId against the origin using the registrable-suffix rule.
function rpIdIsValidForOrigin(rpId: string, origin: string): boolean {
const host = new URL(origin).hostname;
return host === rpId || host.endsWith(`.${rpId}`);
}
// rpIdIsValidForOrigin('example.com', 'https://app.example.com') === true
// rpIdIsValidForOrigin('example.com', 'https://example.org') === false → SecurityError
Step 4 — Emit privacy-safe telemetry
Send the classification and timing, never the credential ID, challenge, or response bytes.
function reportCeremony(outcome: string, ctx: CeremonyContext) {
navigator.sendBeacon('/telemetry/webauthn', JSON.stringify({
outcome, // 'success' | 'timeout' | 'user-dismissed' | ...
type: ctx.type,
mediation: ctx.mediation,
durationMs: Math.round(performance.now() - ctx.startedAt),
engine: navigator.userAgentData?.brands?.map((b) => b.brand).join(',') ?? 'unknown',
// NO credentialId, NO challenge, NO rawId — see GDPR pseudonymisation
}));
}
Validation Checklist
Error Reference Table
| Error / Condition | HTTP Status | Trigger | Diagnostic |
|---|---|---|---|
NotAllowedError (timeout) |
— | Ceremony exceeded timeout |
Compare elapsed vs timeout; raise timeout to 60–120 s |
NotAllowedError (no gesture) |
— | Modal get()/create() without user activation |
Ensure call is inside a click handler (modal path) |
NotAllowedError (dismissed) |
— | User cancelled the prompt | Re-enable button; offer password |
SecurityError |
— | rpId not a suffix of origin, or insecure context |
Run rpIdIsValidForOrigin; require HTTPS |
InvalidStateError |
— | Overlapping ceremony / already registered | Abort prior call; dedupe excludeCredentials |
ConstraintError |
— | UV required, no UV authenticator | Relax UV or offer roaming/hybrid |
NotSupportedError |
— | No supported pubKeyCredParams alg |
Broaden algorithm list (ES256 + RS256) |
Platform and Library Notes
@simplewebauthn/browser
The library re-throws the native DOMException; inspect err.name exactly as with the native API. It also normalises some Safari quirks where the error message differs but the name is stable.
Chrome DevTools
The WebAuthn tab (Settings → More tools → WebAuthn) creates a virtual authenticator so you can reproduce InvalidStateError and ConstraintError without hardware. Toggle “user verification” and “resident key” support to force ConstraintError.
Safari Web Inspector
Safari surfaces less detail; rely on your own context logging. Private Browsing changes platform-authenticator probe results — test both.
Firefox
Conditional mediation support is version-dependent; a NotAllowedError immediately after a conditional call often means the engine ignored mediation:'conditional' and ran a modal without a gesture.
Pitfalls and Security Hardening
1. Reading err.message instead of err.name
Root cause: message text varies by engine and locale. Mitigation: branch only on err.name.
2. Timeouts set too aggressively
Root cause: short timeout misclassifies slow users as failures. Mitigation: 60–120 s; classify by elapsed time.
3. Logging credential material to telemetry Root cause: shipping the whole response object. Mitigation: allowlist non-sensitive fields only.
4. Treating SecurityError as user error
Root cause: generic retry handling. Mitigation: alert engineering — it is always a config bug.
Related
- Frontend UX and Conditional Mediation — the parent pillar and its client-side DOMException map
- Fixing NotAllowedError in WebAuthn Ceremonies — the full decision tree for the catch-all error
- Resolving WebAuthn Timeouts and AbortError — separating expected aborts from real timeouts
- Debugging and Observability for WebAuthn Servers — the server-side counterpart for signature and signCount failures
- Progressive Enhancement and Passkey Fallback Flows — where these errors route to a graceful fallback