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. Callingnavigator.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 withautocomplete="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.
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
- 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. - Omitting the
webauthnautocomplete token. Root cause: standardautocomplete="username"without the extra token. Mitigation: use"username webauthn"— the passkey will never surface otherwise. - Two overlapping ceremonies. Root cause: a modal
get()fired while a conditional call is pending. Mitigation: oneAbortController; abort before every new call. - Treating
NotAllowedErroras 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. - User-agent sniffing instead of feature detection. Root cause: assuming a browser version implies support. Mitigation: call
isConditionalMediationAvailable()andisUserVerifyingPlatformAuthenticatorAvailable()at runtime. - base64url mishandling. Root cause: using standard base64 for the
challengeorallowCredentials[].id. Mitigation: encode and decode with base64url (no padding,-/_alphabet) on every field. - 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
- Conditional Mediation and Passkey Autofill UI — the autofill ceremony,
autocompleteannotation, and one-tap sign-in mechanics - Progressive Enhancement and Passkey Fallback Flows — layering passkeys over a working password form and never stranding a user
- Debugging and Observability for Client-Side WebAuthn — turning
NotAllowedError,AbortError, and timeouts into reproducible diagnoses - Platform vs Roaming Authenticator Trade-offs — how
authenticatorAttachmentshapes the sign-in surface your frontend renders - The Challenge-Response Authentication Flow — what the server does with the assertion your client produces
- Implementing Authentication Verification Logic — server-side verification that is identical regardless of mediation path