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:
- Runs the
get()request without a modal and without requiring a user activation gesture. - Surfaces any discoverable credentials (resident keys) matching the RP ID as autofill suggestions on inputs annotated with the
webauthnautocomplete token. - 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.
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
- Frontend UX and Conditional Mediation — the parent pillar covering the full client-side surface
- Conditional Mediation Browser Support Matrix — which engine versions return
truefromisConditionalMediationAvailable() - Implementing Conditional UI with @simplewebauthn/browser — the library-driven autofill wiring
- Progressive Enhancement and Passkey Fallback Flows — degrading gracefully when conditional mediation is unavailable
- Discoverable vs Server-Side Credentials — why only discoverable resident keys can drive autofill