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.

simplewebauthn autofill pipeline Four-stage pipeline: server generateAuthenticationOptions, browser startAuthentication with useBrowserAutofill, user taps passkey, server verifyAuthenticationResponse. server: generateAuth…Options browser: startAuthentication user taps suggested passkey server: verify…Response

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