What is WebAuthn? What is FIDO2?

The FIDO2 / WebAuthn allows you to create and use strong, attested public key based credentials for the purpose of authenticating users. The API supports the use of BLE, NFC, and USB roaming authenticators (security keys) as well as a platform authenticator, which allows the user to authenticate using their fingerprint or screen lock.

What you'll build...

In this codelab, you are going to build a website with a simple re-authentication functionality using a fingerprint sensor. Re-authentication is when a user signs in to a website once, then authenticates again as they try to enter important sections of the website, or come back after a certain interval, etc in order to protect the accountdata.

What you'll learn...

You will learn how to call the WebAuthn API and options you can provide in order to cater various occasions. You will also learn re-auth specific best practices and tricks.

What you'll need...

Hardware (one of following)

Browser

To work on this codelab, we'll be using a service called glitch. This is where you can edit both client and server side code using JavaScript and deploy them instantly. Head to the following URL:

https://glitch.com/edit/#!/webauthn-codelab-start

See how it works at the beginning

Let's see the initial state of the website first. Click "Show" at the top and press "Next to The Code" to see the live website side by side.

  1. Enter a username and submit (no registration is required, any username will create a new account)
  2. Enter a password and submit (password will be ignored and user will be authenticated nevertheless)
  3. User lands at the home page. Clicking "Sign out" will sign you out. Clicking "Try reauth" sends you back to 2.

Notice that you must enter the password every time you try to sign back in.

What are we going to implement?

  1. Let users register a credential with a "user verifying platform authenticator (UVPA)".
  2. Let users re-authenticate themselves to the app using their biometric without typing the password.

You can preview what you are going to build from here.

Remix the code

In https://glitch.com/edit/#!/webauthn-codelab-start, find the "Remix Project" button at the top left corner. By pressing the button, you can "fork" the project and continue with your own version with a new URL.

Let's move on!

You first need to register a credential generated by a user verifying platform authenticator (UVPA) - an authenticator that is built into the device and verifies the user identity.

We are adding this feature to the /home page.

Create registerCredential() function

Let's create a function called registerCredential() which registers a new credential.

public/client.js

export const registerCredential = async (opts) => {

};

Obtain the challenge and other options from server endpoint: /auth/registerRequest

Before asking the user to register a new credential, ask the server to send back a challenge and other parameters. Call _fetch() with opts as an argument to send a POST request to the server.

public/client.js

const options = await _fetch('/auth/registerRequest', opts);

Here's example options you will receive (aligns with PublicKeyCredentialCreationOptions).

{
  "rp": {
    "name": "WebAuthn Codelab",
    "id": "webauthn-codelab.glitch.me"
  },
  "user": {
    "displayName": "Test",
    "id": "...",
    "name": "test"
  },
  "challenge": "...",
  "pubKeyCredParams": [
    {
      "type": "public-key",
      "alg": -7
    }, {
      "type": "public-key",
      "alg": -257
    }
  ],
  "timeout": 1800000,
  "attestation": "none",
  "excludeCredentials": [
    {
      "id": "...",
      "type": "public-key",
      "transports": [
        "internal"
      ]
    }
  ],
  "authenticatorSelection": {
    "authenticatorAttachment": "platform",
    "userVerification": "required"
  }
}

To learn about these options, see the official specification of the WebAuthn. Some important ones are explained at the "Register a credential" section in the next page.

Create a credential

Because these options are delivered encoded in order to go through HTTP protocol, you have to convert some parameters back to binary - specifically, user.id, challenge and ids included in excludeCredentials array:

public/client.js

options.user.id = base64url.decode(options.user.id);
options.challenge = base64url.decode(options.challenge);

if (options.excludeCredentials) {
  for (let cred of options.excludeCredentials) {
    cred.id = base64url.decode(cred.id);
  }
}

And finally call the navigator.credentials.create() method in order to create a new credential. With this call, the browser will interact with the authenticator and tries to verify the user's identity using the UVPA.

public/client.js

const cred = await navigator.credentials.create({
  publicKey: options
});

Once the user verifies their identity, you should be receiving a credential object you can send to the server and register the authenticator.

Register the credential to the server endpoint: /auth/registerResponse

Here's an example credential object you should have received.

{
  "id": "...",
  "rawId": "...",
  "type": "public-key",
  "response": {
    "clientDataJSON": "...",
    "attestationObject": "..."
  }
}

Like when you received an option object for registering a credential, you should encode the binary parameters of the credential so that it can be delivered to the server as a string.

public/client.js

const credential = {};
credential.id =     cred.id;
credential.rawId =  base64url.encode(cred.rawId);
credential.type =   cred.type;

if (cred.response) {
  const clientDataJSON =
    base64url.encode(cred.response.clientDataJSON);
  const attestationObject =
    base64url.encode(cred.response.attestationObject);
  credential.response = {
    clientDataJSON,
    attestationObject
  };
}

Store the credential id locally so that we can use it for authentication when the user comes back.

public/client.js

localStorage.setItem(`credId`, credential.id);

Finally, send the object to the server and if it returns HTTP code 200, consider the new credential has been successfully registered.

public/client.js

return await _fetch('/auth/registerResponse' , credential);

Congratulations, you now have the complete registerCredential() function!

It will be nice to have a list of registered credentials along with buttons to remove them.

Build UI placeholder

Let's add UI to list credentials and a button to register a new credential. Depending on whether the feature is available or not, we'll remove the hidden class from either the warning message or the button to register a new credential. ul#list will be the placeholder for adding a list of registered credentials.

views/home.html

<p id="uvpa_unavailable" class="hidden">
  This device does not support User Verifying Platform Authenticator. You can't register a credential.
</p>
<h3 class="mdc-typography mdc-typography--headline6">
  Your registered credentials:
</h3>
<section>
  <div id="list"></div>
  <mwc-fab id="register" class="hidden" icon="add"></mwc-fab>
</section>

Feature detection and UVPA availability

To check the UVPA availability, you have to do two things. First, check if WebAuthn is available by examining window.PublicKeyCredential. Second, check if a UVPA is available by calling PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable(). If it's available, we'll show the button to register a new credential. If it's not available, we'll show the warning message.

views/home.html

const register = document.querySelector('#register');
if (window.PublicKeyCredential) {
  PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable()
  .then(UVPAA => {
    if (UVPAA) {
      register.classList.remove('hidden');
    } else {
      document
        .querySelector('#uvpa_unavailable')
        .classList.remove('hidden');
    }
  });        
} else {
  document
    .querySelector('#uvpa_unavailable')
    .classList.remove('hidden');
}

Get a list of credentials and display: getCredentials()

Let's create getCredentials() function so you can get registered credentials and display them in a list. Luckily, we already have a handy endpoint on the server /auth/getKeys which you can fetch registered credentials for the signed-in user.

The returned JSON includes credential information such as id and publicKey. By building HTML you can show them to the user.

views/home.html

const getCredentials = async () => {
  const res = await _fetch('/auth/getKeys');
  const list = document.querySelector('#list');
  const creds = html`${res.credentials.length > 0 ? res.credentials.map(cred => html`
    <div class="mdc-card credential">
      <span class="mdc-typography mdc-typography--body2">${cred.credId}</span>
      <pre class="public-key">${cred.publicKey}</pre>
      <div class="mdc-card__actions">
        <mwc-button id="${cred.credId}" @click="${removeCredential}" raised>Remove</mwc-button>
      </div>
    </div>`) : html`
    <div>No credentials found.</div>
    `}`;
  render(creds, list);
};

Let's display available credentials as soon as the user lands on /home by invoking getCredentials().

views/home.html

getCredentials();

Remove the credential: removeCredential()

In the list of credentials, you have added a button to remove each credential. By sending a request to /auth/removeKey along with the credId query parameter, you can remove them.

public/client.js

export const unregisterCredential = async (credId) => {
  localStorage.removeItem('credId');
  return _fetch(`/auth/removeKey?credId=${encodeURIComponent(credId)}`);
};

views/home.html

import { _fetch, unregisterCredential } from '/client.js';

views/home.html

const removeCredential = async e => {
  try {
    await unregisterCredential(e.target.id);
    getCredentials();
  } catch (e) {
    alert(e);
  }
};

Register a credential

Finally, call registerCredential() to register a new credential when (+) button is clicked. Don't forget to renew the credential list by calling getCredentials() after registration.

Import registerCredential from client.js we created earlier:

views/home.html

import { _fetch, registerCredential, unregisterCredential } from '/client.js';

Invoke registerCredential() with options for navigator.credentials.create() when the button is clicked.

views/home.html

register.addEventListener('click', e => {
  registerCredential({
    attestation: 'none',
    authenticatorSelection: {
      authenticatorAttachment: 'platform',
      userVerification: 'required',
      requireResidentKey: false
    }
  })
  .then(user => {
    getCredentials();
  })
  .catch(e => alert(e));
});

Following are important options to remember to pass in registerCredential()(PublicKeyCredentialCreationOptions we referred earlier).

attestation

Preference for attestation conveyance (none, indirect or direct). Choose none unless you need one.

excludeCredentials

Array of credential descriptors so that the authenticator can avoid creating duplicate ones.

authenticatorSelection

authenticatorAttachment

Filter available authenticators. If you want an authenticator attached to the device, use "platform". For roaming authenticators, use "cross-platform".

userVerification

Determine whether authenticator local user verification is "required", "preferred" or "discouraged". If you want fingerprint or screenlock auth happen, use "required".

requireResidentKey

Use true if the created credential should be available for future "account picker" UX.

OK, you should now be able to register a new credential and display information about those registered credentials. You may try it on your live website.

We now have a credential registered and ready to use it as a way to authenticate the user. Let's add re-auth functionality to the website. Here's the user experience:

As soon as a user lands on /reauth, the website asks for re-auth using a fingerprint. When the user succeeds to authenticate, forward the user to /home, otherwise fallback to use the existing form to enter and submit a password.

Create authenticate() function:

Let's create a function called authenticate() which verifies user identity using a fingerprint. We'll be adding JavaScript code here.

public/client.js

export const authenticate = async (opts) => {

};

Obtain the challenge and other options from server endpoint: /auth/signinRequest

Before authenticating, let's examine if the user has a stored credential id and set it as a query param if they do. By providing a credential id along with other options, the server can provide relevant allowCredentials and this will make user verification reliable.

public/client.js

let url = '/auth/signinRequest';
const credId = localStorage.getItem(`credId`);
if (credId) {
  url += `?credId=${encodeURIComponent(credId)}`;
}

Before asking the user to authenticate, ask the server to send back a challenge and other parameters. Call _fetch() with opts as an argument to send a POST request to the server.

public/client.js

const options = await _fetch(url, opts);

Here's an example options you should be receiving (aligns with PublicKeyCredentialRequestOptions).

{
  "challenge": "...",
  "timeout": 1800000,
  "rpId": "webauthn-codelab.glitch.me",
  "userVerification": "required",
  "allowCredentials": [
    {
      "id": "...",
      "type": "public-key",
      "transports": [
        "internal"
      ]
    }
  ]
}

Most important option here is allowCredentials. When you receive options from the server, allowCredentials should have one of the following statuses:

Status

Explanation

An empty array

There are no credentials stored to the server.

Single object in an array

The specified credential id matched one of credentials stored to the server.

Multiple objects in an array

No credential id was specified.

Let's skip WebAuthn when allowCredentials is an empty array.

if (options.allowCredentials.length === 0) {
  console.info('No registered credentials found.');
  return Promise.resolve(null);
}

Locally verify the user and get a credential

Because these options are delivered encoded in order to go through HTTP protocol, you have to convert some parameters back to binary - specifically, challenge and ids included in allowCredentials array:

public/client.js

options.challenge = base64url.decode(options.challenge);

for (let cred of options.allowCredentials) {
  cred.id = base64url.decode(cred.id);
}

And finally call the navigator.credentials.get() method in order to verify the user's identity using a fingerprint sensor or a screenlock.

public/client.js

const cred = await navigator.credentials.get({
  publicKey: options
});

Once the user verifies their identity, you should be receiving a credential object you can send to the server and authenticate the user.

Verify the credential: /auth/signinResponse

Here's an example credential object you should have received.

{
  "id": "...",
  "type": "public-key",
  "rawId": "...",
  "response": {
    "clientDataJSON": "...",
    "authenticatorData": "...",
    "signature": "...",
    "userHandle": ""
  }
}

Again, encode the binary parameters of the credential so that it can be delivered to the server as a string.

public/client.js

const credential = {};
credential.id =     cred.id;
credential.type =   cred.type;
credential.rawId =  base64url.encode(cred.rawId);

if (cred.response) {
  const clientDataJSON =
    base64url.encode(cred.response.clientDataJSON);
  const authenticatorData =
    base64url.encode(cred.response.authenticatorData);
  const signature =
    base64url.encode(cred.response.signature);
  const userHandle =
    base64url.encode(cred.response.userHandle);
  credential.response = {
    clientDataJSON,
    authenticatorData,
    signature,
    userHandle
  };
}

Don't forget to store the credential id locally so that we can use it for authentication when the user comes back.

public/client.js

localStorage.setItem(`credId`, credential.id);

Finally, send the object to the server and if it returns HTTP code 200, consider the user has been successfully signed-in.

public/client.js

return await _fetch(`/auth/signinResponse`, credential);

Congratulations, you now have the complete authencation() function!

Build UI

When the user comes back, we'd like them to reauth as easily and securely as possible. This is where biometric authentication shines. However, there are cases where:

That is why it's always important that you provide other sign-in options as fallback, and in this codelab we'll use the form based password solution.

Let's add a UI to show an authentication button that invokes the biometric authentication in addition to the password form. We'll use the hidden class to selectively show and hide one of them depending on the user's state.

views/reauth.html

<div id="uvpa_available" class="hidden">
  <h2>
    Verify your identity
  </h2>
  <div>
    <mwc-button id="reauth" raised>Authenticate</mwc-button>
  </div>
  <div>
    <mwc-button id="cancel">Sign-in with password</mwc-button>
  </div>
</div>

Also, append class="hidden" to the form.

views/reauth.html

<form id="form" method="POST" action="/auth/password" class="hidden">

Feature detection and UVPA availability

If one of the following conditions doesn't meet, the user has no choice than signing in with a password:

Selectively show the authentication button or hide it.

views/reauth.html

if (window.PublicKeyCredential) {
  PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable()
  .then(UVPAA => {
    if (UVPAA && localStorage.getItem(`credId`)) {
      document
        .querySelector('#uvpa_available')
        .classList.remove('hidden');
    } else {
      form.classList.remove('hidden');
    }
  });        
} else {
  form.classList.remove('hidden');
}

Fallback to password form

The user should also be able to choose to sign-in with a password. By clicking on "Sign-in with password", show the password form and hide the authentication button.

views/reauth.html

const cancel = document.querySelector('#cancel');
cancel.addEventListener('click', e => {
  form.classList.remove('hidden');
  document
    .querySelector('#uvpa_available')
    .classList.add('hidden');
});

Invoke the biometric authentication

Let's finally enable the biometric authentication. Import the authenticate function from client.js we created earlier.

views/reauth.html

import { _fetch, authenticate } from '/client.js';

Invoke authenticate() when the "Authenticate" button is pressed to start the biometric authentication. Make sure that a failure on biometric authentication will fallback to the password form.

views/reauth.html

const button = document.querySelector('#reauth');
button.addEventListener('click', e => {
  authenticate({}).then(user => {
    if (user) {
      location.href = '/home';
    } else {
      throw 'User not found.';
    }
  }).catch(e => {
    console.error(e.message || e);
    alert('Authentication failed. Use password to sign-in.');
    form.classList.remove('hidden');
    document.querySelector('#uvpa_available').classList.add('hidden');
  });        
});

You have successfully finished the codelab - Your first WebAuthn.

What you've learned

Next step

You can learn both by trying out the Your first Android FIDO2 API codelab!

Resources

Special thanks to Yuriy Ackermann from FIDO Alliance for your help.