Build your first WebAuthn app

1. Before you begin

The Web Authentication API, also known as WebAuthn, lets you create and use origin-scoped, public-key credentials to authenticate users.

The API supports the use of BLE, NFC, and USB-roaming U2F or FIDO2 authenticators—also known as security keys—as well as a platform authenticator, which lets users authenticate with their fingerprints or screen locks.

In this codelab, you build a website with a simple reauthentication functionality that uses a fingerprint sensor. Reauthentication protects account data because it requires users who already signed in to a website to authenticate again when they try to enter important sections of the website or revisit the website after a certain amount of time.

Prerequisites

  • Basic understanding of how WebAuthn works
  • Basic programming skills with JavaScript

What you'll do

  • Build a website with a simple reauthentication functionality that uses a fingerprint sensor

What you'll need

  • One of the following devices:
    • An Android device, preferably with a biometric sensor
    • An iPhone or iPad with Touch ID or Face ID on iOS 14 or higher
    • A MacBook Pro or Air with Touch ID on macOS Big Sur or higher
    • Windows 10 19H1 or higher with Windows Hello set up
  • One of the following browsers:
    • Google Chrome 67 or higher
    • Microsoft Edge 85 or higher
    • Safari 14 or higher

2. Get set up

In this codelab, you use a service called glitch. This is where you can edit client and server-side code with JavaScript, and deploy them instantly.

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

See how it works

Follow these steps to see the initial state of the website:

  1. Click 62bb7a6aac381af8.png Show > 3343769d04c09851.png In a New Window to see the live website.
  2. Enter a username of your choice and click Next.
  3. Enter a password and click Sign-in.

The password is ignored, but you're still authenticated. You land at the home page.

  1. Click Try reauth, and repeat the second, third, and fourth steps.
  2. Click Sign out.

Notice that you must enter the password every time that you try to sign in. This emulates a user who needs to reauthenticate before they can access an important section of a website.

Remix the code

  1. Navigate to WebAuthn / FIDO2 API Codelab.
  2. Click the name of your project > Remix Project 306122647ce93305.png to fork the project and continue with your own version at a new URL.

8d42bd24f0fd185c.png

3. Register a credential with a fingerprint

You need to register a credential generated by a UVPA, an authenticator that is built into the device and verifies the user's identity. This is typically seen as a fingerprint sensor depending on the user's device.

You add this feature to the /home page:

260aab9f1a2587a7.png

Create registerCredential() function

Create a registerCredential() function, which registers a new credential.

public/client.js

export const registerCredential = async () => {

};

Obtain the challenge and other options from server endpoint

Before you ask the user to register a new credential, request that the server return parameters to pass in WebAuthn, including a challenge. Luckily, you already have a server endpoint that responds with such parameters.

Add the following code to registerCredential().

public/client.js

const opts = {
  attestation: 'none',
  authenticatorSelection: {
    authenticatorAttachment: 'platform',
    userVerification: 'required',
    requireResidentKey: false
  }
};

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

The protocol between a server and a client is not a part of the WebAuthn specification. However, this codelab is designed to align with the WebAuthn specification and the JSON object that you pass to the server is very similar to PublicKeyCredentialCreationOptions so that it's intuitive for you. The following table contains the important parameters that you can pass to the server and explains what they do:

Parameters

Descriptions

attestation

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

excludeCredentials

Array of PublicKeyCredentialDescriptor 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 screen-lock authentication, use "required".

requireResidentKey

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

To learn more about these options, see 5.4. Options for Credential Creation (dictionary PublicKeyCredentialCreationOptions).

The following are example options that you receive from the server.

{
  "rp": {
    "name": "WebAuthn Codelab",
    "id": "webauthn-codelab.glitch.me"
  },
  "user": {
    "displayName": "User Name",
    "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"
  }
}

Create a credential

  1. Because these options are delivered encoded to go through HTTP protocol, convert some parameters back to binary, specifically, user.id, challenge and instances of id included in the 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);
  }
}
  1. Call the navigator.credentials.create() method to create a new credential.

With this call, the browser interacts with the authenticator and tries to verify the user's identity with the UVPA.

public/client.js

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

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

Register the credential to the server endpoint

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

{
  "id": "...",
  "rawId": "...",
  "type": "public-key",
  "response": {
    "clientDataJSON": "...",
    "attestationObject": "..."
  }
}
  1. Like when you received an option object for registering a credential, 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,
  };
}
  1. Store the credential ID locally so that you can use it for authentication when the user comes back:

public/client.js

localStorage.setItem(`credId`, credential.id);
  1. Send the object to the server and, if it returns HTTP code 200, consider the new credential as successfully registered.

public/client.js

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

You now have the complete registerCredential() function!

Final code for this section

public/client.js

...
export const registerCredential = async () => {
  const opts = {
    attestation: 'none',
    authenticatorSelection: {
      authenticatorAttachment: 'platform',
      userVerification: 'required',
      requireResidentKey: false
    }
  };

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

  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);
    }
  }
  
  const cred = await navigator.credentials.create({
    publicKey: options
  });

  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
    };
  }

  localStorage.setItem(`credId`, credential.id);
  
  return await _fetch('/auth/registerResponse' , credential);
};
...

4. Build the UI to register, get, and remove credentials

It's nice to have a list of registered credentials and buttons to remove them.

9b5b5ae4a7b316bd.png

Build UI placeholder

Add UI to list credentials and a button to register a new credential. Depending on whether the feature is available or not, you remove the hidden class from either the warning message or the button to register a new credential. ul#list is 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>
</section>
<mwc-button id="register" class="hidden" icon="fingerprint" raised>Add a credential</mwc-button>

Feature detection and UVPA availability

Follow these steps to check the UVPA availability:

  1. Examine window.PublicKeyCredential to check if WebAuthn is available.
  2. Call PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable() to check if a UVPA is available . If they're available, you show the button to register a new credential. If either of them are not available, you 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 and display a list of credentials

  1. Create a getCredentials() function so that you can get registered credentials and display them in a list. Luckily, you already have a handy endpoint on the server /auth/getKeys from which you can fetch registered credentials for the signed-in user.

The returned JSON includes credential information, such as id and publicKey. You can build HTML to 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`
    <p>No credentials found.</p>
    `}`;
  render(creds, list);
};
  1. Invoke getCredentials()to display available credentials as soon as the user lands on the /home page.

views/home.html

getCredentials();

Remove the credential

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

public/client.js

export const unregisterCredential = async (credId) => {
  localStorage.removeItem('credId');
  return _fetch(`/auth/removeKey?credId=${encodeURIComponent(credId)}`);
};
  1. Append unregisterCredential to the existing import statement.

views/home.html

import { _fetch, unregisterCredential } from '/client.js';
  1. Add a function to call when the user clicks Remove.

views/home.html

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

Register a credential

You can call registerCredential() to register a new credential when the user clicks Add a credential.

  1. Append registerCredential to the existing import statement.

views/home.html

import { _fetch, registerCredential, unregisterCredential } from '/client.js';
  1. Invoke registerCredential() with options for navigator.credentials.create().

Don't forget to renew the credential list by calling getCredentials() after registration.

views/home.html

register.addEventListener('click', e => {
  registerCredential().then(user => {
    getCredentials();
  }).catch(e => alert(e));
});

Now you should be able to register a new credential and display information about it. You may try it on your live website.

Final code for this section

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>
      <mwc-button raised><a href="/reauth">Try reauth</a></mwc-button>
      <mwc-button><a href="/auth/signout">Sign out</a></mwc-button>
    </main>
    <script type="module">
      import { _fetch, registerCredential, unregisterCredential } from '/client.js';
      import { html, render } from 'https://unpkg.com/[email protected]/lit-html.js?module';

      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');
      }

      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`
          <p>No credentials found.</p>
          `}`;
        render(creds, list);
      };

      getCredentials();

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

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

public/client.js

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

5. Authenticate the user with a fingerprint

You now have a credential registered and ready to use as a way to authenticate the user. Now you add reauthentication functionality to the website. Here's the user experience:

When a user lands on the /reauth page, they see an Authenticate button if biometric authentication is possible. Authentication with a fingerprint (UVPA) starts when they tap Authenticate, successfully authenticate, and then land on the /home page. If biometric authentication is not available or an authentication with biometric fails, the UI falls back to use the existing password form.

b8770c4e7475b075.png

Create authenticate() function

Create a function called authenticate(), which verifies the user's identity with a fingerprint. You add JavaScript code here:

public/client.js

export const authenticate = async () => {

};

Obtain the challenge and other options from server endpoint

  1. Before authentication, examine if the user has a stored credential ID and set it as a query parameter if they do.

When you provide a credential ID along with other options, the server can provide relevant allowCredentials and this makes user verification reliable.

public/client.js

const opts = {};

let url = '/auth/signinRequest';
const credId = localStorage.getItem(`credId`);
if (credId) {
  url += `?credId=${encodeURIComponent(credId)}`;
}
  1. Before you ask 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 are example options you should receive (aligns with PublicKeyCredentialRequestOptions).

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

The most important option here is allowCredentials. When you receive options from the server, allowCredentials should be either a single object in an array or an empty array depending on whether a credential with the ID in the query parameter is found on the server side.

  1. Resolve the promise with null when allowCredentials is an empty array so that the UI falls back to asking for a password.
if (options.allowCredentials.length === 0) {
  console.info('No registered credentials found.');
  return Promise.resolve(null);
}

Locally verify the user and get a credential

  1. Because these options are delivered encoded in order to go through HTTP protocol, convert some parameters back to binary, specifically challenge and instances of id included in the allowCredentials array:

public/client.js

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

for (let cred of options.allowCredentials) {
  cred.id = base64url.decode(cred.id);
}
  1. Call the navigator.credentials.get() method to verify the user's identity with a UVPA.

public/client.js

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

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

Verify the credential

Here's an example PublicKeyCredential object (response is AuthenticatorAssertionResponse) that you should have received:

{
  "id": "...",
  "type": "public-key",
  "rawId": "...",
  "response": {
    "clientDataJSON": "...",
    "authenticatorData": "...",
    "signature": "...",
    "userHandle": ""
  }
}
  1. 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,
  };
}
  1. Send the object to the server and, if it returns HTTP code 200, consider the user as successfully signed in:

public/client.js

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

You now have the complete authentication() function!

Final code for this section

public/client.js

...
export const authenticate = async () => {
  const opts = {};

  let url = '/auth/signinRequest';
  const credId = localStorage.getItem(`credId`);
  if (credId) {
    url += `?credId=${encodeURIComponent(credId)}`;
  }
  
  const options = await _fetch(url, opts);
  
  if (options.allowCredentials.length === 0) {
    console.info('No registered credentials found.');
    return Promise.resolve(null);
  }

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

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

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

  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,
    };
  }

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

6. Enable reauthentication experience

Build UI

When the user comes back, you want them to reauthenticate as easily and securely as possible. This is where biometric authentication shines. However, there are cases in which biometric authentication may not work:

  • The UVPA is not available.
  • The user has not registered any credentials on their device yet.
  • The storage is cleared and the device no longer remembers the credential ID.
  • The user is unable to verify their identity for some reason, such as when their finger is wet or they're wearing a mask.

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

19da999b0145054.png

  1. Add UI to show an authentication button that invokes the biometric authentication in addition to the password form.

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>
  1. Append class="hidden" to the form:

views/reauth.html

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

Feature detection and UVPA availability

Users must sign in with a password if one of these conditions is met:

  • WebAuthn is not available.
  • UVPA is not available.
  • A credential ID for this UVPA is not discoverable.

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.

Show the password form and hide the authentication button when the user clicks Sign in with password:.

views/reauth.html

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

c4a82800889f078c.png

Invoke the biometric authentication

Finally, enable the biometric authentication.

  1. Append authenticate to the existing import statement:

views/reauth.html

import { _fetch, authenticate } from '/client.js';
  1. Invoke authenticate() when the user taps Authenticate to start the biometric authentication.

Make sure that a failure on biometric authentication falls back 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');
  });        
});

Final code for this section

views/reauth.html

...
    <main class="content">
      <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>
      <form id="form" method="POST" action="/auth/password" class="hidden">
        <h2>
          Enter a password
        </h2>
        <input type="hidden" name="username" value="{{username}}" />
        <div class="mdc-text-field mdc-text-field--filled">
          <span class="mdc-text-field__ripple"></span>
          <label class="mdc-floating-label" id="password-label">password</label>
          <input type="password" class="mdc-text-field__input" aria-labelledby="password-label" name="password" />
          <span class="mdc-line-ripple"></span>
        </div>
        <input type="submit" class="mdc-button mdc-button--raised" value="Sign-In" />
        <p class="instructions">password will be ignored in this demo.</p>
      </form>
    </main>
    <script src="https://unpkg.com/[email protected]/dist/material-components-web.min.js"></script>
    <script type="module">
      new mdc.textField.MDCTextField(document.querySelector('.mdc-text-field'));
      import { _fetch, authenticate } from '/client.js';
      const form = document.querySelector('#form');
      form.addEventListener('submit', e => {
        e.preventDefault();
        const form = new FormData(e.target);
        const cred = {};
        form.forEach((v, k) => cred[k] = v);
        _fetch(e.target.action, cred)
        .then(user => {
          location.href = '/home';
        })
        .catch(e => alert(e));
      });

      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');
      }

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

      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');
        });        
      });
    </script>
...

7. Congratulations!

You finished this codelab!

Learn more

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