Frontend UX and Conditional Mediation

Every WebAuthn deployment succeeds or fails in the browser. The server can verify an attestation statement flawlessly and store a public key credential securely, but if the sign-in form never surfaces the user’s passkey — or throws a NotAllowedError the moment they click — adoption stalls. This reference covers the client-side half of the ceremony: how conditional mediation drives passkey autofill, how to feature-detect capabilities before rendering UI, how to progressively enhance a password form into a passkey-first experience, and how to design fallback flows that never strand a user. It is written for frontend engineers, design-system owners, and product teams shipping passkeys to a heterogeneous fleet of browsers and platforms.

Architecture Overview: Where the Client Sits in the Ceremony

WebAuthn is defined by the W3C (Web Authentication Level 2, with Level 3 in Candidate Recommendation), but the browser is the trust anchor that mediates every call. The client platform — the browser plus the operating system’s credential manager — is responsible for origin validation, user consent, and choosing which authenticator answers a request. The navigator.credentials API is the single surface your frontend touches; everything below it (CTAP2 transport, the authenticator’s secure hardware) is opaque to your JavaScript.

Two request mediations govern how credentials surface to the user, defined in the Credential Management Level 1 spec that WebAuthn extends:

  • Modal mediation (mediation: 'required' or the default 'optional') — the classic pattern. Calling navigator.credentials.get() immediately raises a blocking, browser-native modal (“Use your passkey to sign in”). The user must have initiated the call with a gesture, and the promise rejects if no credential is chosen.
  • Conditional mediation (mediation: 'conditional') — the passkey autofill pattern. The call runs in the background without a modal. The browser decorates matching input fields (those with autocomplete="username webauthn") with the user’s discoverable passkeys, and the ceremony completes only if the user taps one. If they type a password instead, the WebAuthn call quietly stays pending.

The diagram below shows how a single page load fans out into these two paths, and where feature detection gates each decision.

Client-side passkey decision flow Flowchart starting at page load, branching on feature detection into a conditional mediation autofill path and a modal button path, both resolving to navigator.credentials.get, the platform authenticator, and the relying party server. Page load isConditionalMediation Available()? supported Conditional autofill mediation: 'conditional' unsupported Modal button "Sign in with a passkey" navigator.credentials.get() Platform authenticator (UV) POST assertion → RP verify

The critical insight for frontend work: the assertion produced by either path is byte-for-byte identical. Conditional mediation changes only how the credential is surfaced to the user, never the security properties. Your server-side authentication verification logic is agnostic to which path produced the PublicKeyCredential.

Core Concepts

Conditional Mediation (Passkey Autofill)

Conditional mediation lets a passkey appear inside the browser’s autofill dropdown alongside saved usernames, so a returning user signs in with a single tap and no visible “passkey” button. It is requested by passing mediation: 'conditional' to navigator.credentials.get() and marking the relevant input with autocomplete="username webauthn" (W3C WebAuthn L3 §5.1.4, “Conditional User Mediation”). The call must be issued on page load — not behind a click — because it runs silently and waits.

// Kick off conditional mediation as the page becomes interactive.
async function startPasskeyAutofill(challengeOptions: PublicKeyCredentialRequestOptionsJSON) {
  if (!(await isConditionalMediationSupported())) return;

  const abortController = new AbortController();
  try {
    const credential = await navigator.credentials.get({
      publicKey: toPublicKeyRequestOptions(challengeOptions), // decodes base64url challenge
      mediation: 'conditional',
      signal: abortController.signal,
    });
    await postAssertion(credential); // resolves only when the user picks a passkey
  } catch (err) {
    if ((err as DOMException).name !== 'AbortError') reportUnexpected(err);
  }
}

Feature Detection Gates

Never render passkey UI you cannot fulfil. Three orthogonal capabilities must be probed independently before you commit to a path: base API presence (window.PublicKeyCredential), platform authenticator availability (isUserVerifyingPlatformAuthenticatorAvailable()), and conditional mediation support (isConditionalMediationAvailable()). These are defined in WebAuthn L2 §5.1.6–§5.1.7 and are the only reliable signals — user-agent sniffing is not.

export async function isConditionalMediationSupported(): Promise<boolean> {
  return (
    typeof window.PublicKeyCredential !== 'undefined' &&
    // Guard: older engines expose PublicKeyCredential but not this static method.
    typeof PublicKeyCredential.isConditionalMediationAvailable === 'function' &&
    (await PublicKeyCredential.isConditionalMediationAvailable())
  );
}

The browser support matrix for conditional mediation tracks exactly which engine versions return true.

Progressive Enhancement

A passkey experience should be additive: the password form works everywhere, and passkey affordances layer on top only where the platform supports them. This means server-rendering a functional form first, then hydrating conditional mediation and a “Sign in with a passkey” button after feature detection resolves. A JavaScript failure or an unsupported browser degrades to plain password entry, never a broken screen. The progressive enhancement and fallback flows cluster covers the layering discipline in depth.

Abort Signals and Ceremony Coordination

Only one WebAuthn request can be outstanding per page. A conditional mediation call started on load remains pending indefinitely; if the user then clicks an explicit “Sign in with a passkey” button (a modal get()), you must first abort the conditional call or the browser throws InvalidStateError. Every ceremony therefore needs an AbortController whose signal you thread through and cancel on navigation, form reset, or path switch (WebAuthn L2 §5.1, “Abort operations with AbortSignal”).

let inFlight: AbortController | null = null;

function newCeremony(): AbortSignal {
  inFlight?.abort(); // cancel any pending conditional or modal call
  inFlight = new AbortController();
  return inFlight.signal;
}

Autofill Annotation

The browser only offers passkeys on inputs it recognises as sign-in fields. The autocomplete token "webauthn" must be appended to an existing hint ("username webauthn" or "username email webauthn"); using "webauthn" alone, or omitting it, prevents the passkey from ever appearing in the dropdown even when conditional mediation is active. This is the single most common reason autofill “silently does nothing” — see debugging passkey autofill that never appears.

<input
  type="text"
  name="username"
  autocomplete="username webauthn"
  autofocus
/>

Validation and Security Boundaries

The browser enforces hard boundaries that no client code can override, and understanding them prevents whole classes of “it works locally but not in production” bugs. Origin binding is absolute: navigator.credentials.get() only offers credentials whose RP ID is a registrable-suffix match of the caller’s origin, and the call is refused outright on insecure origins (WebAuthn L2 §5.1.4). Work through this checklist before shipping any passkey sign-in surface:

Client-Side DOMException Mapping

DOMException Typical trigger (client) UX response
NotAllowedError User dismissed the prompt, timeout elapsed, or the call lacked a user gesture Re-enable the button; show “Try again or use your password”
SecurityError rpId is not a registrable-suffix match of the origin, or the page is not HTTPS Log and alert engineering — this is a config bug, not user error
InvalidStateError A credential already exists (registration), or an overlapping ceremony is in flight Abort the prior ceremony; on registration, treat as “already enrolled”
AbortError The AbortSignal was triggered (path switch, navigation) Silent — expected control flow
NotSupportedError Requested algorithm/params unsupported by the authenticator Fall back to a broader pubKeyCredParams set or password
ConstraintError userVerification: 'required' but no UV-capable authenticator Offer a roaming key or password fallback

The client-side debugging cluster turns each of these into a reproducible diagnosis. For a byte-level view of what the RP validates once the assertion arrives, cross-reference the challenge-response authentication flow.

Cross-Platform and Cross-Device Considerations

Frontend behaviour diverges sharply across platforms, and the conditional mediation support matrix is the single biggest source of “works on my machine” defects. The table below summarises current behaviour; treat it as a decision aid and verify with real feature detection at runtime rather than hardcoding it.

Platform / Engine Conditional mediation Platform authenticator Cross-device (hybrid) sign-in
iOS/iPadOS Safari 16+ Yes Face ID / Touch ID Yes — QR to another device
macOS Safari 16+ Yes Touch ID Yes
Android Chrome 108+ Yes Google Password Manager biometrics Yes
Desktop Chrome 108+ Yes Windows Hello / Touch ID Yes — QR + Bluetooth proximity
Desktop Firefox Partial / version-dependent Platform-dependent Limited
Windows Hello (Edge/Chrome) Yes PIN / biometric Yes

Two behaviours deserve frontend attention. First, sync propagation: a passkey created on one Apple device appears on the user’s other devices via iCloud Keychain within seconds to minutes, but never instantly — never assume a just-registered credential is immediately usable on a sibling device. Second, hybrid transport (formerly caBLE) lets a user sign in on a laptop using a passkey held on their phone, brokered over Bluetooth proximity and a QR handshake; your UI should present this as a natural “use a different device” option rather than a fallback. The platform vs roaming authenticator trade-offs page details the underlying authenticatorAttachment choices that shape these flows.

Compliance Mapping

Frontend choices carry compliance weight because they determine which authenticator and verification level actually reaches the server. The table maps client-side controls to the regimes most passkey deployments must satisfy.

Frontend control NIST SP 800-63B FIDO2 Security Requirements PSD2 SCA GDPR
userVerification: 'required' AAL2/AAL3 (verifier-impersonation resistance) UV enforced at authenticator Inherence/knowledge factor
Origin/RP ID binding in get() Phishing resistance (AAL3) Cryptographic origin binding Dynamic linking of transaction
No PII in user.name/user.displayName Data minimisation (Art. 5)
Fallback to password gated by policy AAL step-down must be explicit Two-factor retention
Client-side telemetry excludes credential IDs Credential-ID confidentiality Pseudonymisation

Because the UV flag is set by the authenticator but requested by your frontend, a mislabelled userVerification: 'discouraged' on the client silently downgrades the entire ceremony below AAL2 even if the server would have accepted UV. Frontend and backend policy must be declared identically.

Common Pitfalls

  1. Starting conditional mediation behind a click. Root cause: treating mediation: 'conditional' like a modal call. Mitigation: issue it once on page load; use the button only for the explicit modal path.
  2. Omitting the webauthn autocomplete token. Root cause: standard autocomplete="username" without the extra token. Mitigation: use "username webauthn" — the passkey will never surface otherwise.
  3. Two overlapping ceremonies. Root cause: a modal get() fired while a conditional call is pending. Mitigation: one AbortController; abort before every new call.
  4. Treating NotAllowedError as fatal. Root cause: mapping every rejection to an error screen. Mitigation: re-enable the form and offer a retry — most rejections are a dismissed prompt.
  5. User-agent sniffing instead of feature detection. Root cause: assuming a browser version implies support. Mitigation: call isConditionalMediationAvailable() and isUserVerifyingPlatformAuthenticatorAvailable() at runtime.
  6. base64url mishandling. Root cause: using standard base64 for the challenge or allowCredentials[].id. Mitigation: encode and decode with base64url (no padding, -/_ alphabet) on every field.
  7. Blocking first paint on feature detection. Root cause: awaiting async probes before rendering the form. Mitigation: render the password form immediately; enhance after the probes resolve.

Related