← Back home

July 19th, 2024

Minimizing WebAuthn Conditional UI latency

Or: Why are passkeys not showing up for some users on autofill when using Conditional UI?

WebAuthn's Conditional UI feature lets you add the user's passkeys as autofill entries on any input field. This is very useful if your site is not ready to show a big "sign in with a passkey" button on its front page, e.g. becuase a significant number of your users don't have a passkey and you want to avoid the awkwarndess of a modal dialog error if they tap that button.

Cropped screenshot of
Chrome showing a passkey on an autofill popup

Running a Conditional UI WebAuthn request is pretty simple. You tag an input field with autocomplete="webauthn".

<label for="username">Username:</label>
<input type="text" value="username" autocomplete="webauthn">

and then run the request like normal, with mediation: "conditional".

navigator.credentials.get({
  mediation: "conditional",
  publicKey: {...}  // your WebAuthn request.
});

If you already have a WebAuthn implementation, this probably looks pretty easy and that's because it is! However, it's crucial to minimize the time between your site loading the input field and the WebAuthn request being executed. If you don't, the autofill box may render before the browser had a chance to load the user's passkeys. In fact, we've had multiple reports for input fields sometimes not showing passkeys, and it turned out to be an excessive time gap between the form being rendered and the WebAuthn request, leading Chrome to show the autofill UI before it ever knew the site supported passkeys.

Death by three cuts

Let's look at a typical front-end WebAuthn implementation across three synchronous network calls:

site.html

<body>  <!-- first network call loads the html -->
  <label for="username">Username:</label>
  <input type="text" value="username" autocomplete="webauthn">
  <script src="webauthn.js"></script>  <!-- second network call -->
</body>

webauthn.js

let options = await fetch("options.json");  // third network call.
navigator.credentials.get({
  mediation: "optional",
  publicKey: fromJSON(options),
});

This can be illustrated by the following timing diagram:

 site.html
----------+
          | webauthn.js 
          +-------------+
          |             | options.json
          |             +--------------+>
          |                            |
          +                            +
   browser ready to              browser knows it's
     show autofill            supposed to show passkeys
----------+----------------------------+-----------> time
          |          time gap          |
          |<-------------------------->|

If the user taps the input field between the browser being ready to show autofill and the browser knowing it's supposed to show passkeys, your users will not see any passkeys. This can also happen if the field is autofocused and the time time between rendering and the WebAuthn request exceeds the browser's grace period (which for Chrome, at the time of writing, is 200ms). This is easy to exceed on a bad mobile connection or if your server is far away from your users.

Bridging the gap

The solution should be pretty obvious: embed the javascript and the options (including the challenge) on the same document to reduce the gap without compromising the time it takes to render the page itself.

site.html

<label for="username">Username:</label>
<input type="text" value="username" autocomplete="webauthn">

<!-- rest of the page -->

<script>
let options = {...};  // embed a static set of options.
options.challenge = ...;  // embed challenge or generate from a timestamp.
navigator.credentials.get({
  mediation: "optional",
  publicKey: options,
});
</script>

This reduces the time gap to the time it takes the browser to process your WebAuthn request and load the passkeys. That's a lot faster than two network requests.

If this looks iffy to you because mixing declarative user interface and logic in the same source file is considered "not pure", don't cringe! Remember it's users we're trying to delight, and usually they're not programmers who'll look at your source code. It makes a huge difference to usability to mimize the gap.