Secure your site with two-factor authentication with a security key (WebAuthn)

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.

16ce77744061c5f7.png

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.example can't be used on evil-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.

6539dc7ffec2538c.png

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

dd56e2cfe0f7ced2.png

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.

6b49ff0298f5a0af.png

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

ffebe58ac826eaf2.png 852de328fcd4eb42.png

  • 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.

fc0acf00a4d412fa.png

  • 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.

ffebe58ac826eaf2.png 9fe75f04e43da035.png

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

923d5adb8aa8286c.png

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

fc0acf00a4d412fa.png

  • 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!

7e1c0bb19c9f3043.png

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.

cf2b9f552c9809b6.png

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 decodeServerOptions to decode them.
  • It creates a credential by calling the web API navigator.credential.create. When navigator.credential.create is 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/credential that 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:

  • rpName and rpId describe the organization that registers and authenticates the user. Remember that in WebAuthn, credentials are scoped to a certain domain, which is a security benefit; rpName and rpId here are used to scope the credential. A valid rpId is for example the hostname of your site. Note how these will be automatically updated as you fork the starter project 🧘🏻‍♀️
  • excludeCredentials is a list of credentials; the new credential can't be created on an authenticator that also contains one of the credentials listed in excludeCredentials. In our codelab, excludeCredentials is a list of existing credentials for this user. With this and user.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.
  • authenticatorSelection defines the type of authenticators you want to allow in your web application. Let's take a closer look at authenticatorSelection:
    • residentKey: preferred means 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 up preferred because this codelab focuses on the basic implementation; discoverable credentials are for more advanced flows.
    • requireResidentKey is only present for backwards-compatibility with WebAuthn v1.
    • userVerification: preferred means 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.
  • ​​pubKeyCredParam describes, 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})`)}`