1. What you'll build
You'll start with a basic web application that supports password-based login.
You'll then add support for two-factor authentication via a security key, based on WebAuthn. To do so, you'll implement the following:
- A way for a user to register a WebAuthn credential.
- A two-factor-authentication flow where the user is asked for their second factor—a WebAuthn credential—if they've registered one.
- A credential management interface: a list of credentials that enables users to rename and delete credentials.

Take a look at the finished web app and try it out.
2. About WebAuthn
WebAuthn basics
Why WebAuthn?
Phishing is a massive security issue on the web: most account breaches leverage weak or stolen passwords that are reused across sites. The industry's collective response to this problem has been multi-factor authentication, but implementations are fragmented and many still don't adequately address phishing.
The Web Authentication API, or WebAuthn, is a standardized phishing-resistant protocol that can be used by any web application.
How it works
Source: webauthn.guide
WebAuthn allows servers to register and authenticate users using public key cryptography instead of a password. Websites can create a credential, consisting of a private-public keypair.
- The private key is stored securely on the user's device.
- The public key and randomly generated credential ID are sent to the server for storage.
The public key is used by the server to prove the user's identity. It's not secret, because it's useless without the corresponding private key.
Benefits
WebAuthn has two main benefits:
- No shared secret: the server stores no secret. This makes databases less attractive to hackers, because the public keys aren't useful to them.
- Scoped credentials: a credential registered for
site.examplecan't be used onevil-site.example. This makes WebAuthn phishing-proof.
Use cases
One use case for WebAuthn is two-factor authentication with a security key. This may be especially relevant for enterprise web applications.
Browser support
It's written by the W3C and FIDO, with the participation of Google, Mozilla, Microsoft, Yubico, and others.
Glossary
- Authenticator: a software or hardware entity that can register a user and later assert possession of the registered credential. There are two types of authenticators:
- Roaming authenticator: an authenticator usable with any device the user is trying to sign-in from. Example: a USB security key, a smartphone.
- Platform authenticator: an authenticator that is built into a user's device. Example: Apple's Touch ID.
- Credential: the private-public keypair
- Relying party: the (server for) the website that is trying to authenticate the user
- FIDO server: the server that is used for authentication. FIDO is a family of protocols developed by the FIDO alliance; one of these protocols is WebAuthn.
In this workshop, we'll use a roaming authenticator.
3. Before you begin
What you'll need
To complete this codelab, you'll need:
- A basic understanding of WebAuthn.
- Basic knowledge of JavaScript and HTML.
- An up-to-date browser that supports WebAuthn.
- A security key that is U2F-compliant.
You can use one of the following as a security key:
- An Android phone with Android>=7 (Nougat) that runs Chrome. In this case, you'll also need a Windows, macOS, or ChromeOS machine with working Bluetooth.
- A USB key, such as a YubiKey.

Source: https://www.yubico.com/products/security-key/

What you'll learn
You will learn ✅
- How to register and use a security key as a second factor for WebAuthn authentication.
- How to make this process user-friendly.
You won't learn ❌
- How to build a FIDO server—the server that is used for authentication. This is OK because typically, as a web application or site developer, you would rely on existing FIDO server implementations. Make sure to always verify the functionality and quality of the server implementations you rely on. In this codelab, the FIDO server uses SimpleWebAuthn. For other options, see the FIDO Alliance official page. For open source libraries, see webauthn.io or AwesomeWebAuthn.
Disclaimer
The user must enter a password to sign in. However, for simplicity in this codelab the password isn't stored nor checked. In a real application, you would check that it's correct server-side.
Basic security checks such as CSRF checks, session validation, and input sanitizing are implemented in this codelab. However, many security measures are not—for example, there's no input limit on passwords to prevent brute-force attacks. It doesn't matter here because passwords are not stored, but make sure to not use this code as-is in production.
4. Set up your authenticator
If you're using an Android phone as an authenticator
- Make sure Chrome is up to date on both your desktop and your phone.
- On both your desktop and your phone, open Chrome and sign in with the same profile⏤the profile you wish to use for this workshop.
- Turn on Sync for this profile, on your desktop and phone. Use chrome://settings/syncSetup for this.
- Turn on Bluetooth on both your desktop and your phone.
- In Chrome desktop logged-in with the same profile, open webauthn.io.
- Enter a simple username. Leave the Attestation type and Authenticator type to the None and Unspecified (default) values. Click Register.

- A browser window should open, asking you to verify your identity. Select your phone in the list.

- On your phone, you should get a notification titled Verify your identity. Tap it.
- On your phone, you'll be asked for your phone's PIN code (or to touch the fingerprint sensor). Enter it.
- On webauthn.io on your desktop, a "Success" indicator should appear.

- On webauthn.io on your desktop, click the Login button.
- Again, a browser window should open; select your phone in the list.
- On your phone, tap the notification that pops up, and enter your PIN (or touch the fingerprint sensor).
- webauthn.io should tell you that you're logged in. Your phone is working properly as a security key; you're all set for the workshop!
If you're using a USB security key as an authenticator
- In Chrome desktop, open webauthn.io.
- Enter a simple username. Leave the Attestation type and Authenticator type to the None and Unspecified (default) values. Click Register.
- A browser window should open, asking you to verify your identity. Select USB security key in the list.

- Insert your security key into your desktop and touch it.

- On webauthn.io on your desktop, a "Success" indicator should appear.

- On webauthn.io on your desktop, click the Login button.
- Again, a browser window should open; select USB security key in the list.
- Touch the key.
- Webauthn.io should tell you that you're logged in. Your USB security key is working properly; you're all set for the workshop!

5. Get set up
In this codelab, you'll use Glitch, an online code editor that automatically and instantly deploys your code.
Fork the starter code
Open the starter project.
Click the Remix button.
This creates a copy of the starter code. You now have your own code to edit. Your fork (called "remix" in Glitch) is where you'll do all of the work for this codelab.

Explore the starter code
Explore the starter code you've just forked for a bit.
Observe that under libs, a library called auth.js is already provided. It's a custom library that takes care of the server-side authentication logic. It uses the fido library as a dependency.
6. Implement credential registration
Implement credential registration
The first thing we need in order to set up two-factor authentication with a security key is to enable the user to create a credential.
Let's first add a function that does this in our client-side code.
In public/auth.client.js, note that there's a function called registerCredential()that doesn't do anything just yet. Add the following code to it:
async function registerCredential() {
// Fetch the credential creation options from the backend
const credentialCreationOptionsFromServer = await _fetch(
"/auth/credential-options",
"POST"
);
// Decode the credential creation options
const credentialCreationOptions = decodeServerOptions(
credentialCreationOptionsFromServer
);
// Create a credential via the browser API; this will prompt the user to touch their security key or tap a button on their phone
const credential = await navigator.credentials.create({
publicKey: {
...credentialCreationOptions,
}
});
// Encode the newly created credential to send it to the backend
const encodedCredential = encodeCredential(credential);
// Send the encoded credential to the backend for storage
return await _fetch("/auth/credential", "POST", encodedCredential);
}
Note that this function is already exported for you.
Here's what registerCredential does:
- It fetches the credential creation options from the server (
/auth/credential-options) - Because the server options come back encoded, it uses the utility function
decodeServerOptionsto decode them. - It creates a credential by calling the web API
navigator.credential.create. Whennavigator.credential.createis called, the browser takes over and prompts the user to choose a security key. - It decodes the newly created credential
- It registers the new credential server-side by making a request to
/auth/credentialthat contains the encoded credential.
Aside: take a look at the server code
registerCredential() makes two calls to the server, so let's take a moment to look at what's happening in the backend.
Credential creation options
When the client makes a request to (/auth/credential-options), the server generates an options object and sends it back to the client.
This object is then used by the client in the actual credential creation call:
navigator.credentials.create({
publicKey: {
// Options generated server-side
...credentialCreationOptions
// ...
}
So, what's in this credentialCreationOptions that's ultimately used in the client-side registerCredential you've implemented in the previous step?
Take a look at the server code under router.post("/credential-options", ....
Let's not look at every single property, but here are a few interesting ones that you can see in the server code's options object, that's generated using the fido2 library and ultimately returned to the client:
rpNameandrpIddescribe the organization that registers and authenticates the user. Remember that in WebAuthn, credentials are scoped to a certain domain, which is a security benefit;rpNameandrpIdhere are used to scope the credential. A validrpIdis for example the hostname of your site. Note how these will be automatically updated as you fork the starter project 🧘🏻♀️excludeCredentialsis a list of credentials; the new credential can't be created on an authenticator that also contains one of the credentials listed inexcludeCredentials. In our codelab,excludeCredentialsis a list of existing credentials for this user. With this anduser.id, we're ensuring that each credential a user creates will live on a different authenticator (security key). This is a good practice because it means that if a user has registered multiple credentials, they'll be on different authenticators (security keys), so losing one security key wouldn't lock the user out of their account.authenticatorSelectiondefines the type of authenticators you want to allow in your web application. Let's take a closer look atauthenticatorSelection:residentKey: preferredmeans that this application doesn't enforce client-side discoverable credentials. A client-side discoverable credential is a special type of credential that makes it possible to authenticate a user without needing to first identify them. Here, we've set uppreferredbecause this codelab focuses on the basic implementation; discoverable credentials are for more advanced flows.requireResidentKeyis only present for backwards-compatibility with WebAuthn v1.userVerification: preferredmeans that if the authenticator supports user verification—for example, if it's a biometric security key or a key with a built-in PIN feature—the relying party will request it when creating the credential. If the authenticator doesn't—basic security key—then the server will not request user verification.
pubKeyCredParamdescribes, in order of preference, the desired cryptographic properties of the credential.
All these options are decisions that the web application needs to make for its security model. Observe that on the server, these options are defined in a single authSettings object.
Challenge
Another more interesting bit here is req.session.challenge = options.challenge;.
Because WebAuthn is a cryptographic protocol, it depends upon randomized challenges to avoid replay attacks—when an attacker steals a payload to replay the authentication, when they aren't the owner of the private key that would enable authentication.
To mitigate this, a challenge is generated on the server, and will be signed on the fly; the signature will then be compared with what's expected. This verifies that the user detains the private key at the time of credential generation.
Credential registration code
Take a look at the server code under router.post("/credential", ....
This is where the credential gets registered server-side.
So, what's going on there?
One of the most noteworthy bits in this code is the verification call, via fido2.verifyAttestationResponse:
- The signed challenge is checked, and this ensures that the credential was created by someone who actually detained the private key at creation time.
- The relying party's ID, bound to its origin, is also verified. This ensures that the credential is bound to this web application (and only this web application).
Add this functionality to the UI
Now that your function to create a credential, ``registerCredential(),is ready, let's make it available to the user.
You're going to do this from the Account page, because this is a usual location for authentication management.
In account.html's markup, below the username, there's a so-far empty div with a layout class class="flex-h-between". We'll use this div for UI elements that relate to 2FA functionality.
Add ino this div:
- A title that says "Two-factor authentication"
- A button to create a credential
<div class="flex-h-between">
<h3>
Two-factor authentication
</h3>
<button class="create" id="registerButton" raised>
➕ Add a credential
</button>
</div>
Below this div, add a credential div that we'll need later:
<div class="flex-h-between">
(HTML you've just added)
</div>
<div id="credentials"></div>
In account.html inline script, import the function you've just created and add a function register that calls it, as well as an event handler attached to the button you've just created.
// Set up the handler for the button that registers credentials
const registerButton = document.querySelector('#registerButton');
registerButton.addEventListener('click', register);
// Register a credential
async function register() {
let user = {};
try {
const user = await registerCredential();
} catch (e) {
// Alert the user that something went wrong
if (Array.isArray(e)) {
alert(
// `msg` not `message`, this is the key's name as per the express validator API
`Registration failed. ${e.map((err) => `${err.msg} (${err.param})`)}`