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:
- Click Show > In a New Window to see the live website.
- Enter a username of your choice and click Next.
- Enter a password and click Sign-in.
The password is ignored, but you're still authenticated. You land at the home page.
- Click Try reauth, and repeat the second, third, and fourth steps.
- 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
- Navigate to WebAuthn / FIDO2 API Codelab.
- Click the name of your project > Remix Project to fork the project and continue with your own version at a new URL.
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:
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 | ||
| Preference for attestation conveyance— | ||
| Array of | ||
|
| Filter available authenticators. If you want an authenticator attached to the device, use " | |
| Determine whether authenticator local user verification is " | ||
| Use |
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
- Because these options are delivered encoded to go through HTTP protocol, convert some parameters back to binary, specifically,
user.id
,challenge
and instances ofid
included in theexcludeCredentials
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);
}
}
- 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": "..."
}
}
- 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,
};
}
- 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);
- 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.
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:
- Examine
window.PublicKeyCredential
to check if WebAuthn is available. - 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
- 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);
};
- 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)}`);
};
- Append
unregisterCredential
to the existingimport
statement.
views/home.html
import { _fetch, unregisterCredential } from '/client.js';
- 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.
- Append
registerCredential
to the existingimport
statement.
views/home.html
import { _fetch, registerCredential, unregisterCredential } from '/client.js';
- Invoke
registerCredential()
with options fornavigator.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/lit-html@1.0.0/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.
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
- 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)}`;
}
- Before you ask the user to authenticate, ask the server to send back a challenge and other parameters. Call
_fetch()
withopts
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.
- Resolve the promise with
null
whenallowCredentials
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
- Because these options are delivered encoded in order to go through HTTP protocol, convert some parameters back to binary, specifically
challenge
and instances ofid
included in theallowCredentials
array:
public/client.js
options.challenge = base64url.decode(options.challenge);
for (let cred of options.allowCredentials) {
cred.id = base64url.decode(cred.id);
}
- 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": ""
}
}
- 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,
};
}
- 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.
- 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>
- 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');
});
Invoke the biometric authentication
Finally, enable the biometric authentication.
- Append
authenticate
to the existingimport
statement:
views/reauth.html
import { _fetch, authenticate } from '/client.js';
- 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/material-components-web@7.0.0/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
- Web Authentication: An API for accessing Public Key Credentials Level 1
- Introduction to WebAuthn API
- FIDO WebAuthn Workshop
- WebAuthn Guide: DUOSEC
- Your first Android FIDO2 API
Special thanks to Yuriy Ackermann from FIDO Alliance for your help.