Conditional Mediation and Passkey Autofill UI

Conditional mediation is what makes a passkey feel like magic: a returning user focuses the username field, sees their passkey offered in the same dropdown as saved usernames, taps it, and is signed in — no button, no modal, no password. This page is the implementation reference for that flow. It covers the exact autocomplete annotation the browser requires, the non-blocking navigator.credentials.get() call, the abort coordination that keeps it from colliding with a modal ceremony, and the library-specific helpers that remove the base64url boilerplate. For the wider client-side picture this fits into, see Frontend UX and Conditional Mediation.


Concept Definition and Spec Grounding

Conditional mediation is defined in WebAuthn Level 3 §5.1.4 as a value of the CredentialMediationRequirement enum inherited from Credential Management Level 1. When mediation is 'conditional', the user agent:

  1. Runs the get() request without a modal and without requiring a user activation gesture.
  2. Surfaces any discoverable credentials (resident keys) matching the RP ID as autofill suggestions on inputs annotated with the webauthn autocomplete token.
  3. Leaves the returned promise pending until the user either selects a passkey (resolve) or the ceremony is aborted (reject with AbortError).

Two preconditions are absolute. The credential must be discoverable — conditional mediation cannot surface a server-side credential the authenticator cannot enumerate, which is why the choice between discoverable vs server-side credentials directly determines whether autofill is even possible. And the input must carry the webauthn token appended to a real autocomplete hint. The browser treats autocomplete="username webauthn" as the trigger; the bare token autocomplete="webauthn" is ignored by current engines.

The autocomplete contract

<!-- Correct: the webauthn token is appended to a standard hint -->
<input name="username" autocomplete="username webauthn" />

<!-- Also valid for email-first flows -->
<input name="email" type="email" autocomplete="email webauthn" />

<!-- Wrong: no passkey will ever surface here -->
<input name="username" autocomplete="webauthn" />

Architecture and Data Flow

The sequence below shows how the conditional call lives alongside ordinary form interaction. Note that the WebAuthn request is issued once, early, and simply waits — the user’s tap on a suggested passkey is what resolves it.

Conditional mediation autofill sequence Sequence across user, page script, browser credential UI, authenticator, and relying party showing a pending get() call resolved by a user tapping a suggested passkey. User Page script Browser UI Authenticator RP GET /assertion/options (fresh challenge) challenge + rpId get({mediation:'conditional'}) pending (no modal) focus username input dropdown: saved usernames + passkeys tap suggested passkey UV + sign(challenge) assertion promise resolves POST assertion → verify signature + signCount Set-Cookie session → signed in

Implementation Guide

Step 1 — Feature detect before rendering (WebAuthn L2 §5.1.6)

Gate the entire flow on the static probe. If it resolves false, render only the password path.

export async function isConditionalMediationSupported(): Promise<boolean> {
  return (
    typeof window.PublicKeyCredential !== 'undefined' &&
    typeof PublicKeyCredential.isConditionalMediationAvailable === 'function' &&
    (await PublicKeyCredential.isConditionalMediationAvailable())
  );
}

Step 2 — Annotate the input (WebAuthn L3 §5.1.4)

The webauthn token is the browser’s signal to decorate this field with passkeys.

<form id="signin">
  <input name="username" autocomplete="username webauthn" autofocus />
  <button type="submit">Continue</button>
</form>

Step 3 — Fetch fresh options from the RP

The challenge must be a server-generated CSPRNG value; reusing it enables replay. This mirrors the requirements in best practices for FIDO2 challenge generation.

async function getAssertionOptions(): Promise<PublicKeyCredentialRequestOptionsJSON> {
  const res = await fetch('/api/assertion/options', { method: 'POST' });
  if (!res.ok) throw new Error(`options failed: ${res.status}`);
  return res.json(); // { challenge, rpId, allowCredentials?, userVerification, timeout }
}

Step 4 — Start the conditional call on page load

Issue get() with mediation: 'conditional' and a coordinating AbortSignal. Because the promise stays pending, this must never block first paint — call it from an idle callback after the form is interactive.

let ceremony: AbortController | null = null;

export async function startAutofill() {
  if (!(await isConditionalMediationSupported())) return;

  ceremony?.abort();
  ceremony = new AbortController();

  const options = await getAssertionOptions();
  try {
    const credential = (await navigator.credentials.get({
      publicKey: {
        ...options,
        challenge: base64urlToBuffer(options.challenge),
        allowCredentials: (options.allowCredentials ?? []).map((c) => ({
          ...c,
          id: base64urlToBuffer(c.id),
        })),
      } as PublicKeyCredentialRequestOptions,
      mediation: 'conditional',
      signal: ceremony.signal,
    })) as PublicKeyCredential;

    await verifyAssertion(credential);
  } catch (err) {
    if ((err as DOMException).name !== 'AbortError') throw err;
  }
}

document.addEventListener('DOMContentLoaded', () => {
  ('requestIdleCallback' in window ? requestIdleCallback : setTimeout)(() => startAutofill());
});

Step 5 — Serialise and post the assertion

Encode every ArrayBuffer back to base64url before sending, so the authentication verification logic receives canonical JSON.

async function verifyAssertion(cred: PublicKeyCredential) {
  const r = cred.response as AuthenticatorAssertionResponse;
  const body = {
    id: cred.id,
    rawId: bufferToBase64url(cred.rawId),
    type: cred.type,
    response: {
      clientDataJSON: bufferToBase64url(r.clientDataJSON),
      authenticatorData: bufferToBase64url(r.authenticatorData),
      signature: bufferToBase64url(r.signature),
      userHandle: r.userHandle ? bufferToBase64url(r.userHandle) : null,
    },
  };
  const res = await fetch('/api/assertion/verify', {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify(body),
  });
  if (res.ok) location.assign('/dashboard');
}

Validation Checklist


Error Reference Table

Error / Condition HTTP Status Trigger Diagnostic
No passkeys appear in dropdown Missing webauthn autocomplete token or non-discoverable credential Inspect input autocomplete; confirm credential was created with residentKey: 'required'
AbortError Ceremony signal aborted (path switch/navigation) Expected; verify it is caught and ignored
InvalidStateError Overlapping modal get() while conditional pending Ensure prior ceremony is aborted before the modal call
SecurityError rpId not a suffix of origin, or insecure origin Check location.hostname vs rpId; require HTTPS
NotAllowedError on modal fallback Prompt dismissed / timeout Re-enable button; offer password
Assertion 400 at RP 400 base64url mis-encoding of response fields Log raw body; verify base64url alphabet and padding

Platform and Library Notes

@simplewebauthn/browser

startAuthentication() accepts { useBrowserAutofill: true }, which sets mediation: 'conditional' and handles all base64url conversion. It also calls browserSupportsWebAuthnAutofill() internally. See implementing conditional UI with @simplewebauthn/browser for the full wiring.

Native (no library)

You own the base64url helpers and the AbortController. Prefer ArrayBuffer ⇄ base64url utilities that reject standard base64 input, to catch encoding bugs at the boundary.

iOS / Safari

Safari 16+ supports conditional mediation but is stricter about the autocomplete token and requires the input to be visible and focusable. A hidden or display:none input will not receive passkey suggestions.

Android / Chrome

Chrome surfaces passkeys from Google Password Manager. If a user has no synced passkey but the account exists, the dropdown shows only saved usernames — plan a modal “Sign in with a passkey” fallback for first-time cross-device use.


Pitfalls and Security Hardening

1. Standalone autocomplete="webauthn" Root cause: dropping the base hint. Mitigation: always append the token — "username webauthn" or "email webauthn".

2. Conditional call fired behind a gesture Root cause: wiring startAutofill() to a click handler. Mitigation: call it on DOMContentLoaded/idle; the browser handles user interaction internally.

3. Reusing a challenge across ceremonies Root cause: caching options client-side. Mitigation: fetch a fresh challenge per ceremony; the server must one-time-use it.

4. Non-discoverable credentials expected to autofill Root cause: registering with residentKey: 'discouraged'. Mitigation: require discoverable credentials for autofill sign-in, or provide a username-first modal path.

5. Blocking render on the probe Root cause: await isConditionalMediationAvailable() before painting. Mitigation: render form first, enhance after.

6. Leaking credential IDs to analytics Root cause: logging the assertion body. Mitigation: strip rawId/id from client telemetry.


Related