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 own AbortSignal fired; expected control flow, not a failure.
  • SecurityErrorrpId is not a registrable-suffix match of the origin, or the page is not a secure context.
  • InvalidStateError — a credential already exists for excludeCredentials (registration), or an overlapping ceremony is in flight.
  • ConstraintError — a required option (typically userVerification: 'required') cannot be satisfied by the available authenticator.
  • NotSupportedError — none of the requested pubKeyCredParams algorithms 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.

Client-side WebAuthn error triage tree Triage flowchart routing a caught DOMException by name into root-cause buckets: gesture and timeout for NotAllowedError, config for SecurityError, ceremony coordination for InvalidStateError, and expected control flow for AbortError. catch (err) → err.name NotAllowedError SecurityError InvalidStateError AbortError gesture missing? timeout too short? user dismissed? rpId ≠ origin suffix not HTTPS → fix config overlapping get() already registered → abort / dedupe expected — swallow silently Emit ceremony telemetry

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