Implementing Conditional UI with @simplewebauthn/browser
@simplewebauthn/browser removes the two most error-prone parts of passkey autofill — base64url conversion and the conditional mediation plumbing — behind a single startAuthentication({ useBrowserAutofill: true }) call. This page is the end-to-end wiring: the feature probe, the autofill call, abort coordination with an explicit button, and the matching @simplewebauthn/server endpoints. It sits under Conditional Mediation and Passkey Autofill UI; for the raw-API version of the same flow, see that parent page.
API Surface and Constraints
The relevant exports and their preconditions:
| Export | Purpose | Constraint |
|---|---|---|
browserSupportsWebAuthn() |
API presence probe | Gate the modal button on this |
browserSupportsWebAuthnAutofill() |
Conditional mediation probe | Wraps isConditionalMediationAvailable() |
startAuthentication({ optionsJSON, useBrowserAutofill }) |
Runs the ceremony | useBrowserAutofill: true → conditional; input needs autocomplete="username webauthn" |
startRegistration({ optionsJSON }) |
Registration ceremony | Set residentKey: 'required' server-side for autofill |
WebAuthnAbortService |
Cancels a pending ceremony | Call before starting an overlapping one |
The library takes JSON options straight from @simplewebauthn/server (v10+ uses the optionsJSON field) and returns a JSON-serialisable response, so no manual ArrayBuffer handling is required.
Root Cause Analysis (common wiring mistakes)
1. Passing v9-style options to v10+. The response/options field names changed; a mismatch throws before the ceremony starts. Confirm your @simplewebauthn/browser and @simplewebauthn/server majors match.
2. Forgetting useBrowserAutofill: true. Without it the library runs a modal ceremony that needs a gesture, so autofill never surfaces.
3. No abort before the modal button. Clicking “Sign in with a passkey” while the autofill call is pending throws InvalidStateError; call WebAuthnAbortService.cancelCeremony() first.
Step-by-Step Resolution
Step 1 — Client: probe, then start autofill on load
import {
startAuthentication,
browserSupportsWebAuthnAutofill,
browserSupportsWebAuthn,
WebAuthnAbortService,
} from '@simplewebauthn/browser';
export async function initPasskeyAutofill() {
if (!(await browserSupportsWebAuthnAutofill())) return;
const optionsJSON = await (await fetch('/api/auth/options', { method: 'POST' })).json();
try {
const asseResp = await startAuthentication({ optionsJSON, useBrowserAutofill: true });
const verify = await fetch('/api/auth/verify', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(asseResp),
});
if (verify.ok) location.assign('/dashboard');
} catch (err) {
if ((err as Error).name !== 'AbortError') console.error(err);
}
}
document.addEventListener('DOMContentLoaded', () => {
if (browserSupportsWebAuthn()) initPasskeyAutofill();
});
Step 2 — Client: coordinate the explicit button
document.getElementById('passkey-btn')?.addEventListener('click', async () => {
WebAuthnAbortService.cancelCeremony(); // abort the pending autofill call
const optionsJSON = await (await fetch('/api/auth/options', { method: 'POST' })).json();
const asseResp = await startAuthentication({ optionsJSON }); // modal, no autofill
await fetch('/api/auth/verify', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(asseResp),
});
});
Step 3 — Server: options and verification
The server side is the same regardless of mediation; see handling WebAuthn signature verification in Node.js for the verification internals.
import {
generateAuthenticationOptions,
verifyAuthenticationResponse,
} from '@simplewebauthn/server';
// POST /api/auth/options
const options = await generateAuthenticationOptions({
rpID: 'example.com',
userVerification: 'preferred',
allowCredentials: [], // empty → discoverable-credential (usernameless) autofill
});
req.session.challenge = options.challenge;
res.json(options);
Leaving allowCredentials empty is what enables usernameless autofill: the browser offers any discoverable passkey for the RP ID rather than a server-pinned list.
Verification and Testing
# Confirm matching majors so option/response shapes align
npm ls @simplewebauthn/browser @simplewebauthn/server
In the browser console, await browserSupportsWebAuthnAutofill() should return true, and focusing the annotated field should surface the passkey. Use the DevTools WebAuthn virtual authenticator (resident key enabled) to register and then test autofill without hardware. Assert in an integration test that /api/auth/options returns an empty allowCredentials for the usernameless path.
Pitfalls
1. Mismatched library majors. Option/response field renames between versions break the ceremony silently. Pin and verify both packages.
2. Non-empty allowCredentials for autofill. Pinning credentials defeats usernameless autofill. Leave it empty for the conditional path.
3. Not cancelling the pending ceremony. The explicit button throws InvalidStateError without cancelCeremony().
Related
- Conditional Mediation and Passkey Autofill UI — the raw-API version of this flow and the
autocompletecontract - Conditional Mediation Browser Support Matrix — when
browserSupportsWebAuthnAutofill()returnsfalse - Handling WebAuthn Signature Verification in Node.js — the server verification
startAuthenticationfeeds