Money Forward Developers Blog

株式会社マネーフォワード公式開発者向けブログです。技術や開発手法、イベント登壇などを発信します。サービスに関するご質問は、各サービス窓口までご連絡ください。

20230215130734

Future of passwordless sign-in using passkey autofill

Japanese version of this article is available here

Intro

Hey, I am yamato(@8ma10s) from Money Forward's CTO Office ID Service Development Division. Our team develops Money Forward ID, the IdP for various services of Money Forward.

We have released a new feature called passwordless sign-in, which allows users to sign into Money Forward ID without using a password.

We decided to write a blog post about this release because our passwordless UI uses a new type of passwordless sign-in UI called Passkey autofill, probably the first of its kind in Japan, and possibly the first in the world, as a end-user facing service. Using passkey autofill, we released our passkey support based on browser autocompletion instead of traditional, button-based WebAuthn UI.

I will explain how and why we decided to implement and release Passkey autofill, challenges we faced, and thoughts for possible future improvements of WebAuthn standards.

Phase 0: Before passwordless

We wanted to implement passwordless authentication for many reasons, but here are the few of the main reasons:

  • Improve sign-in UX (in many cases, facial recognition or fingerprint recognition is faster than putting in a password)
  • Reduce "I forgot my password!" experience
  • Take on new technical challenges

We might want to do things like "disable password sign-in" in the future, but we decided to support password sign-in with the simple intention of enabling users to access the services they want to use more quickly.

Phase 1: Traditional WebAuthn

Actually, the implementation of passwordless sign-in was already available to the end-users as of November 2022. However, we did not make any public announcement at the time due to the issues we will describe later in this article.

The flow of traditional WebAuthn released at that time was as follows:

  • User enters email
  • Upon pressing "Sign in" button, authentication prompt is displayed
  • Once local authentication 1 is complete, passkey sign-in is complete

You may be thinking, "Isn't passkey sign-in already complete at this point? Why would you go further?" However, there were several issues in this initial implementation.

Display authentication prompt upon button press is very unintuitive

Traditional WebAuthn's sign-in flow asks the user to authenticate via the prompt when the user enters their email and click the sign-in button. This UI requires the user to go though facial or fingerprint authentication out of nowhere. It's the kind of UI that never existed before WebAuthn, and it was a factor that hindered the seamless transition from passwords.

Authentication prompt still appears on devices in which the passkey is not available

As stated in WebAuthn Spec, there is basically no way for the WebAuthn RP2 to check from the browser "whether the corresponding passkey for the information held by the server exists on the device" before calling credentials.get.

So, if you try to log into an account with passkey already registered, and the device being used to sign in doesn't have the corresponding passkey, our service will still call credentials.get. This behavior results in the following prompt.

In order to cancel passkey sign-in and proceed to the password input page, the user must press "cancel" on the prompt. Such behavior is extremely confusing to the users, and annoying those who are simply trying to sign in.

Phase 2: Passkey Autofill

As a so-called "WebAuthn RP" like MoneyForward ID, the following two points were essential requirements for passkey implementation.

  1. make the passkey experience seamless and similar to the UX that users are already familiar with
  2. even after the introduction of the passkey, the UX of the normal password sign-in is not compromised

However, we already knew during the initial implementation that the so-called "Traditional WebAuthn" had the aforementioned issues, that the transition would not be seamless, and that the UX of users using passwords would be compromised, so we did not make an explicit release announcement when the initial implementation was released.

The next step we took to solve these problems was to implement passkey autofill.

What is passkey autofill?

This technology is called "conditional mediation" or "conditional UI" in the WebAuthn community.

In May 2022, Apple, Google, and Microsoft issued a joint statement stating that they will "expand support for passkey. Later, as part of that effort, the passkey autofill was mentioned in WWDC22 and Google's Passkey Support Announcement as a mechanism to support passkeys using a more user-friendly way.

Using passkey autofill, we can display available passkeys, and only the passkeys available for sign-in on that particular device, using browser autocompletion. Rather than me trying to explaing this further with text, please take a look at the following video to see what I'm talking about.

So, does passkey autofill solve the issues with traditional WebAuthn?

Yes, and no. Passkey autofill solves some of the issues that Traditional WebAuthn did not solve, especially the ones that were mentioned in this article.

Make the passkey seamless and similar to the UX that users are already familiar with

As mentioned above, in Traditional WebAuthn, there was concern that the UI would be dramatically different from that of password sign-in, leading to user confusion.

With Passkey autofill, the sign-in process is as follows:

  • Available passkeys are automatically displayed below the email input field
  • Authentication prompt appears when user clicks on one of the passkeys
  • Once the user goes through local authentication, sign-in is complete!

One thing we should note is that this process is very similar to the process of username/password autocompletion that comes with almost any browsers. This similarity is very important in the widening the usage of passkeys instead of passwords. Rather than trying to explicitly make users be aware of passkeys, we can accomplish the transition by not making users be aware of the difference between passkey and password in the first place.

No loss of UX for normal password sign-in after introduction of passkey

In Traditional WebAuthn, if users try to sign in from a device different from the one that registered the passkey, an authentication prompt appeared even though passkeys are unavailable.

With passkey autofill, only passkeys available on the device are displayed. Therefore, we can provide a passkey sign-in with a more ideal UX because we can ask users to sign in with a passkey only if passkeys are available, and lead them to the password input screen otherwise.

Example implementation

Now that we have introduced a brief overview of the issues that passkey solves, let me introduce the actual code.

Traditional WebAuthn

We will not discuss traditional WebAuthn that does not use Passkey autofill here, as there are already many articles explaining it.

In understanding the Passkey autofill code example, I think it would be enough for you to know the general flow of WebAuthn authentication:

  • Get an options object (an object that defines settings during authentication) from the backend
  • call navigator.credentials.get with the obtained options object as an argument
  • Validate the return value of the get method on the backend.

If you would like to learn more about Traditional WebAuthn, the following codelabs and others may be helpful. https://developers.google.com/codelabs/webauthn-reauth

Passkey autofill overall

First, a sequence diagram of the entire process.

And here is a sample of the front-end code that runs when the page loads. note: Checks such as whether the browser provides a specific API are omitted.

const sendAuthenticatorResponseIfWebauthnAvailable = async () => {
  try {
    // if browser is webauthn-compatible, fetch options from server
    if (!(navigator.credentials &&
      navigator.credentials.create &&
      navigator.credentials.get &&
      window.PublicKeyCredential &&
      await PublicKeyCredential.isConditionalMediationAvailable())) {
      return false;
    }

    const optionsJSON = await backend.fetchWebauthnAssertionOptions();
    if (optionsJSON != null) {
      options = webauthn.parseRequestOptionsFromJSON(optionsJSON);
    } else {
      return null;
    }

    options['mediation'] = 'conditional';

    const response = await navigator.credentials.get(options);
    return await backend.postWebauthnAssertion(response.toJSON()); // send the authenticator response to the backend
  } catch (e) {
    console.log(e);
    return null;
  }
};

In the step-by-step explanation below, I will refer to these code and the diagram.

On page load

In traditional WebAuthn, most processing is done when the Submit button is pressed. But in passkey autofill, most processing is done on page load.

  • Check for availability of WebAuthn API and conditional mediation
  • get options object from backend
  • Call credentials.get
  • (set autocomplete value)

So, the above code (using useEffect or something similar in React) should be run when the page you want to use passkey autofill on loads.

Check for availability of WebAuthn API and conditional mediation (Sequence Diagram 1, 2)

First, we need to check if WebAuthn is available on the front end and if conditional mediation (= passkey autofill) is available.

For conditional mediation, there is an asynchronous method called PublicKey.isConditionalMediationAvailable(), so you can check the result of this method 3.

// if browser is webauthn-compatible, fetch options from server
if (!(navigator.credentials &&
  navigator.credentials.create &&
  navigator.credentials.get &&
  window.PublicKeyCredential &&
  await PublicKeyCredential.isConditionalMediationAvailable())) {
  return false;
}

get options object from backend (sequence diagram 3,4)

Next, we need an options object to pass to credentials.get. The challenge in the options object should be generated by the backend, so the frontend sends the request to the backend to receive the options.

const optionsJSON = await backend.fetchWebauthnAssertionOptions();
if (optionsJSON != null) {
  options = webauthn.parseRequestOptionsFromJSON(optionsJSON);
} else {
  return null;
}
const fetchWebauthnAssertionOptions = async () => {
  const response = await fetch(options_webauthn_assertion_path(), {
    method: 'POST',
    body: {},
    headers: {
      Accept: 'application/json',
      'Content-Type': 'application/json',
    },
  });

  switch (response.status) {
    case 200:
      return response.json();
    default:
      return null;
  }
};

As a result, the backend returns an options object with empty allowCredentials as follows

{
  "publicKey": {
    "challenge": "DZ5BwnKQoeJK9RPrB0FEyjD7qnFLXUsEZ8lPKnK_jzU",
    "timeout": 120000,
    "extensions": {},
    "allowCredentials": [],
    "userVerification": "required"
  }
}

For passkey autofill sign-ins, we want to display all client-side credentials stored by the client as candidates, not the server-side credentials specified by the server, and allow the user to sign in with any available passkeys (=any account). So, allowCredentials should be an empty array.

Call credentials.get (Sequence Diagram 5,6)

All that is left is to call credentials.get with the obtained options object as an argument, but if we call the method with the options object above, it will not be conditional mediation. Rather, it will be a normal credentials.get call like traditional WebAuthn, and user will end up getting authentication prompt upon page load. We don't want that.

To prevent that, we need to add mediation: "conditional".

options['mediation'] = 'conditional';
const response = await navigator.credentials.get(options);

Once that's done, we can pass such options to credentials.get.

{
  "publicKey": {
    "challenge": "DZ5BwnKQoeJK9RPrB0FEyjD7qnFLXUsEZ8lPKnK_jzU",
    "timeout": 120000,
    "extensions": {},
    "allowCredentials": [],
    "userVerification": "required"
  },
  "mediation": "conditional"
}

The credentials.get called with mediation: "conditional" will not resolve the Promise until the user selects a available passkey and completes authentication. so, the code below the credentials.get call will await forever until the user starts operating on the page.

Set autocomplete value

This is technically not the part of the page load process, but I will mention it here, because we won't see any passkey suggestions just by executing the above code on the page load. In addition to the code above, we need to modify input element on the page that displays the passkey autofill.

The browser will display passkey suggestions on the field if the valuewebauthn is set to autocomplete field. Therefore, we need to add webauthn to the autocomplete value of the input field for which you want to display passkey suggestions.

<input
    required
    type="email"
    name="email"
    autoComplete="username webauthn"
/>

Now when the user opens the page, the available passkeys are displayed.

During user operation

Once the passkey autofill is enabled on the sign-in page, we can expect (mainly) one of the following two situations once the user starts interacting with the page:

  • User clicks on the passkey suggestions
  • No passkey is available, or user ignores the passkeys displayed and submit the email

User clicks on the passkey suggestions (Sequence diagram 7-11)

If one of the passkey suggestion is clicked and the user completes local authentication, the credentials.get called at page load time is resolved and the response from the authenticator is returned. So, we simply send the response to backend and verify it. If the verification is successful, we let the user sign in.

const response = await navigator.credentials.get(options); // Promise will be resolved when local authentication succeeds
return await backend.postWebauthnAssertion(response.toJSON()); // send the authenticator response to the backend

// redirect, sign in user, etc...

No passkey is available, or user ignores the passkeys displayed and submit the email

In this case, since this is a normal form submission process, normal handling is all that is required. In the case of our service, we direct the user to a password entry page.

So, what issues are remaining?

Thus, while passkey autofill makes the transition from passkey sign-ins and passwords significantly more seamless, there are some remaining issues.

How can we achieve seamless passwordless sign-in on pages with no form fields

As mentioned above, calling credentials.get with mediation: "conditional" will cause passkey candidates to be displayed in the input field with a specific autocomplete value. This in turn means that there is no way to display passkey candidates with passkey autofill on pages where the input element does not exist.

You might say, "Do such pages even exist?" Well, yes. Money Forward ID has a page called account confirmation screen.

This is a page that is displayed when user tries to SSO to MoneyForward service (personal finance management, Cloud Accounting, etc.) to make sure that the account they are logging into is correct. Since the input field does not exist in such a page in the first place, Passkey autofill (conditional mediation) cannot be used, and as a result, we are forced to use traditional WebAuthn approach.

Ideally, we would like to provide an experience that makes the user unaware that they are using a passkey, even on these pages where such an input field does not exist.

Registering passkey is (still) a hassle

Passkey autofill has improved the passkey sign-in experience, but the passkey registration process hasn't improved.

This article mainly focused on sign-in with passkeys, but from the perspective of a WebAuthn RP developer, the whole effort is meaningless if users do not choose to register their passkeys in the first place.

However, in the current WebAuthn specification, there is no option such as mediation: "conditional" in credentials.create (registration API), so we MUST go through the following registration flow:

  • Display an extra page or modal to the user, and urge them to register
  • If the user agrees, register the passkey after the authentication prompt.

In an IdP such as Money Forward ID, there are only two main types of timing for displaying such prompt: account registration and sign-in.

Unfortunately, as you can imagine, users hate being interrupted by an extra page on either of these timing. From the user's point of view, the IdP is an obstacle, a barrier, that they must pass through in order to use the service they want to use. It's ironic that we need to introduce an extra barrier for users when our intent is to get rid of such barriers by enabling sign-in with passkeys.

With regards to this issue, we hope to see a WebAuthn specification that allows for something like the following:

  • When setting a password upon account registration, the user can register a passkey at the same time.
  • (If the user is using an OS password manager, etc.) When logging in with a password, they can register a passkey at the same time.

We believe that allowing such an implementation would eliminate the need for users to be aware of passkey even on registration process.

One more thing... Password managers and one-step sign-in

Along with passkey autofill, we released a related feature called one-step sign-in.

Passkey autofill does not display passkey candidates unless the autocomplete value in the input field contains webauthn, but some password managers and extensions modify the DOM and overwrite this autocomplete value to off. (This is probably to avoid conflicts between the password manager's own auto-completion and the browser's auto-completion.)

Since we realized that passkey could not be used under these conditions once we start using passkey autofill, we have released a one-step sign-in feature that allows users to sign in without displaying the password entry screen if they have used a password manager or some kind of password autocompletion.

The following is a one-step sign-in process.

  • Below the Email input field, the password manager will display the password candidates it has
  • User clicks on one of them. (Depending on password manager settings, an authentication prompt may appear.)
  • Press the sign-in button. User is signed in!

As you can see by comparing with Passkey autofill, the apparent UX from the user's point of view is almost the same. With these changes, these two completely different sign-in method will have almost the same sign-in experience, form the PoV of the user.

  • Password sign-in when using browser auto-completion, password manager, etc.
  • Passkey sign-in using Passkey autofill

Through these changes, we would like to provide a UX in which users can sign in without making users be aware of differences in sign-in methods.

Annotation


  1. Authentication methods that can be used to use a passkey on a device. Depending on the type of device, PIN, facial recognition, fingerprint recognition, etc.
  2. RP in WebAuthn is a service that uses the Webauthn API Webauthn API. In this case, MoneyForward ID is an WebAuthn RP.
  3. In order to use passkey autofill (conditional mediation), you SHOULD make sure that the return value of this method is true and WebAuthn W3C spec,-PublicKeyCredential%20overrides%20this).