12 min read

Getting Started with Passkeys and WebAuthn

Table of Contents

Talk slides | Sample app | Source code

Presented by Lucas Castro at the UtahJS Conference, 13 September 2024 in Salt Lake City, UT.

ℹ️

This is essentially a script that I wrote for my talk, and I’m sharing it here for anyone who wants to read it. I’ve added some notes and comments to help you understand the context of the talk.

Introduction


Thanks, everyone! It’s a great pleasure to be here with you. UtahJS has a fantastic community, and even though I’m based in Texas, I always make sure to fly out here for this conference.

So, as you can see, I’ll be talking about passkeys today. I’ve become quite enamored with them and spent some time researching them while building a key product feature at my job that required passkeys.

I’m super excited to share with you some of what I’ve learned, and hopefully, get you excited to learn more about passkeys and go out there and build cool stuff with them.

What We Will Cover


If you read the abstract for this presentation, you’ll know that my intent with this talk is to be practical. We’re going to look at code, and we’ll spend time talking about implementation details.

However, I do think it’s important to lay out a theoretical foundation first. So, we will be spending some time talking about the specifications, definitions, etc., because I believe this will help us better understand the practical side and get you excited to explore more.

Here’s what we’re going to cover:

  • We’ll talk about the WebAuthn spec, focusing on some definitions and prescriptions.
  • We’ll cover passkeys and create a distinction between them and the WebAuthn spec.
  • We’ll address the problems these two are trying to solve.
  • We’ll get more practical and look at how we implement WebAuthn in a web application.
  • We’ll discuss some limitations and concerns with WebAuthn.

We won’t cover these items on the right. They’re interesting discussions but outside the scope of this talk.

What Is the Problem?


So, what is the problem? Why do we need WebAuthn? Why do we have passkeys? What is the intent here? Let’s take a look at some interesting trends here:

Passwords Are the Problem


I know I said before that I wouldn’t make comparisons. The reason I’m even talking about passwords here is that I don’t consider passwords as a serious authentication method.

They are too vulnerable. At a minimum, you’ll need hashing and salting. Then you need to enforce password hygiene through length and special character requirements. If you want to get serious, you’ll need to implement a rotation period, forcing users to update their passwords periodically. Not to mention that you’ll need to implement a recovery flow as well. And even after all that, you still won’t be able to prevent your users from reusing passwords, and they’ll still be vulnerable to phishing.

Don’t get me wrong, I’m not a password hater, and WebAuthn wasn’t created to destroy passwords. The real problem is that we rely too much on a factor of knowledge to authenticate users. That’s insecure because knowledge alone cannot guarantee a user’s identity.

WebAuthn


All right, Lucas, but what is WebAuthn then?

WebAuthn is a standard, just like OAuth 2.0 is. It’s defined through a specification written in a joint effort by the FIDO Alliance (the people behind security key technology) and the W3C. Some other big companies are involved too, but these two organizations are the ones setting the standard.

WebAuthn specifies how relying parties (verifiers) can register and authenticate users with public-key cryptography by working with authenticators (devices). The spec outlines use cases, prescribes an API, and defines a data model so web applications can communicate with authenticators.

Support

It is well-supported across the major browsers. Note that the partial support for Firefox is due to Firefox having poor support for Touch ID (apparently).

Passkeys


There isn’t a clear line that defines the differences between WebAuthn and passkeys.

The terms are used interchangeably most of the time. The difference might pop up when someone is talking about using WebAuthn in the context of passwordless authentication. WebAuthn was conceptualized as a second-factor authentication (so on top of your password, for example).

Passkeys represent a paradigm shift, relying on OS support and cross-device synchronization so users can always have access to their passkeys on any device they may be using.

For this talk: WebAuthn is the specification, and passkey is the “discoverable” credential.

Use Cases


Registration: Creating a key pair, storing the public key on the server, and tying that to a user account.

Authentication/Verification: Using the public key to create an authentication challenge and requesting the authenticator to use the private key to sign the challenge and verify it.

Registration and Verification Flows

I created this diagram to talk about how the data flows in the use cases, and what each party is doing

WebAuthn diagram

The user interacts with the authenticator and the RP. The user prompts the RP to initiate the flows and uses gestures (Touch ID, Face ID, PINs, inserting keys, etc.) to authorize the authenticator to create a key pair or use the private key.

The RP comprises the server and the client. The server prepares the requests, stores the public key, and manages user sessions. The client interfaces directly with the authenticator, prompting it for a key pair or the private key.

The authenticator is the OS or the physical device that generates the key pair and holds onto the private key. It requires interaction with the user to perform its actions.

Sample Application


We’re using a few libraries to help us out.

On the server side, it’s highly recommended to use a library that implements the spec. On the client side, you can use the native web API of your browser. I’ll be using a library just to speed things up and highlight the concepts.

Server: Start Registration

async function startRegistration(email: string, name: string) {
  const [result] = await db.select().from(users).where(eq(users.email, email));

  if (result) {
    throw new AppError("This email is already registered");
  }

  const userId = uuid();

  await db.insert(users).values({ email, name, id: userId }).execute();

  const registrationOptions = await generateRegistrationOptions({
    rpName: constants.rpName,
    rpID: constants.rpId,
    userID: isoUint8Array.fromUTF8String(userId),
    userName: email,
    userDisplayName: name,
    timeout: 60_000,
    attestationType: "none",
    excludeCredentials: [], // This should be a list of existing credential IDs for that user. Prevents re-registration with the same device.
    authenticatorSelection: {
      userVerification: "preferred", // This is required for "passkeys"
      residentKey: "required", // This is required for "passkeys"
    },
    supportedAlgorithmIDs: [-7, -257],
  });

  await db
    .insert(challenges)
    .values({ value: registrationOptions.challenge, userId })
    .execute();

  return registrationOptions;
}

Things to highlight:

  • The rpId must be consistent across your application. It should always be the host where the key pairs were created.
  • You can tie some very basic information about the user, like their ID, name, and display name.
  • I don’t care about attestation in this example, but if you do, you can specify “direct,” for example, and then you’ll receive the certificate together with the public key. How you validate that is up to you.
  • We are not excluding any credentials because in this example, a user can only have one passkey, and a user can only create an account once, so it makes no sense that the user would have another here. If specified, this would force the authenticator to create a new key pair instead of reusing an old one.
  • Authenticator selection is important. We use user verification preferred to require a gesture from the user at key creation, but we don’t want it to necessarily fail if the user can’t provide one. In the end, whether it fails or not is up to the authenticator.
  • resident key required makes it so this credential can be “discoverable”. In other words, the WebAuthn request doesn’t need to specify a credential ID, and then the authenticator will enumerate to the user the available credentials to the user, and then the user can choose which credential to use. Passkeys rely on resident keys.

Client: Prompt Registration

async function registerPasskey(event: SubmitEvent) {
  event.preventDefault();

  const email = registrationEmail.value;
  const name = registrationName.value;

  const options = await window.app.api.requestRegistrationOptions(email, name);

  if (!options) {
    return;
  }

  options.extensions = {
    credProps: true,
  };

  let registration;

  try {
    registration = await startRegistration(options); // This will prompt the authenticator, function exposed by simple-webauthn library
  } catch (error: any) {
    console.error(error);
  }

  await window.app.api.sendRegistrationResponse(email, registration);
}

We prepare most of the options on the server because of encoding standards that the library guarantees, but also because of the challenge tied to the request. We can continue adding options on the client. Here, we just want to request some additional properties about the credential after its creation, like whether or not it is discoverable.

Line 14 will initiate communication with the authenticator, and then the authenticator will prompt a gesture from the user. We forward the results back to the server.

Server: Finish Registration

async function completeRegistration(
  email: string,
  response: RegistrationResponseJSON,
) {
  const [user] = await db.select().from(users).where(eq(users.email, email));

  if (!user) {
    throw new AppError("User not found", 404);
  }

  const [challenge] = await db
    .select()
    .from(challenges)
    .where(eq(challenges.userId, user.id));

  if (!challenge) {
    throw new AppError("This user currently has no active challenge", 401);
  }

  const verification = await verifyRegistrationResponse({
    response,
    expectedChallenge: challenge.value,
    expectedOrigin: constants.acceptedOrigin,
    expectedRPID: constants.rpId,
    requireUserVerification: true,
  });

  if (!verification.verified) {
    throw new Error("Registration verification failed");
  }

  if (!verification.registrationInfo) {
    throw new Error("No registration info available");
  }

  await db
    .insert(credentials)
    .values({
      id: verification.registrationInfo.credentialID,
      credentialPublicKey: verification.registrationInfo.credentialPublicKey, //
      counter: verification.registrationInfo.counter,
      transports: response.response.transports,
      userId: user.id,
    })
    .execute();

  await db
    .delete(challenges)
    .where(eq(challenges.value, challenge.value))
    .execute();
}

Straightforward verification—if successful, we finish user registration.

Server: Start Authentication

async function startAuthentication(email: string) {
  const [user] = await db.select().from(users).where(eq(users.email, email));

  if (!user) {
    throw new AppError("Cannot authenticate this user");
  }

  const passkeys = await db
    .select()
    .from(credentials)
    .where(eq(credentials.userId, user.id));

  const authenticationOptions = await generateAuthenticationOptions({
    rpID: constants.rpId,
    userVerification: "required",
    timeout: 60_000,
    allowCredentials: passkeys.map((credential) => {
      const transports = credential.transports as string;
      return {
        id: credential.id,
        transports: transports.split(",") as AuthenticatorTransportFuture[],
      };
    }),
  });

  await db
    .insert(challenges)
    .values({ value: authenticationOptions.challenge, userId: user.id })
    .execute();

  return authenticationOptions;
}

Starting authentication is pretty simple. We can specify similar options. The most important part is to actually request the authenticator for the right credentials.

Client: Prompt Authentication

async function authenticatePasskey(event: SubmitEvent) {
  event.preventDefault();

  const email = authEmail.value;

  const options = await window.app.api.requestAuthenticationOptions(email);

  if (!options) {
    return;
  }

  let authentication;
  try {
    authentication = await startAuthentication(options);
  } catch (error: any) {
    console.error(error);
  }

  await window.app.api.sendAuthenticationResponse(email, authentication);
}

Simple stuff.

Server: Finish Authentication

async function completeAuthentication(
  email: string,
  response: AuthenticationResponseJSON,
) {
  const [user] = await db.select().from(users).where(eq(users.email, email));

  if (!user) {
    throw new AppError("Cannot authenticate this user");
  }

  const [challenge] = await db
    .select()
    .from(challenges)
    .where(eq(challenges.userId, user.id));

  if (!challenge) {
    throw new AppError("Cannot authenticate this user");
  }

  const [credential] = await db
    .select()
    .from(credentials)
    .where(eq(credentials.id, response.rawId));

  if (!credential) {
    throw new AppError("Cannot authenticate this user");
  }

  const transports = credential.transports as string;

  const auth = await verifyAuthenticationResponse({
    response,
    expectedChallenge: challenge.value,
    expectedOrigin: constants.acceptedOrigin,
    expectedRPID: constants.rpId,
    authenticator: {
      credentialID: credential.id,
      credentialPublicKey: credential.credentialPublicKey as Uint8Array,
      counter: credential.counter,
      transports: transports.split(",") as AuthenticatorTransportFuture[],
    },
    requireUserVerification: true,
  });

  if (!auth.verified) {
    throw new AppError("Cannot authenticate this user");
  }

  if (auth.authenticationInfo) {
    await db
      .update(credentials)
      .set({ counter: auth.authenticationInfo.newCounter })
      .where(eq(credentials.id, credential.id))
      .execute();
  }

  await db
    .delete(challenges)
    .where(eq(challenges.value, challenge.value))
    .execute();

  return { id: user.id, name: user.name, email: user.email };
}

Here we just need to match the signed challenge to the right credential. We also update the counter of that credential. Keeping a counter is important because it’s a way to mitigate one of the security risks with passkeys. Passkeys could potentially be cloned. Pretty hard to do, but your hardware security module theoretically can be cloned. If we keep a counter, we can potentially detect clones, even though I’m not doing anything with it here.

Limitations


Here are some concerns with my implementation and with passkeys in general.

  • You probably should design your WebAuthn flows around the idea of sessions.
  • If the user loses access to the passkey, they lose access to the account.
  • There’s no support for an RP to discover resident credentials in the authenticator.

Resources


I highly recommend checking these out. The spec is long and focused on people who actually implement the APIs, but oh man, there’s a ton of interesting things to learn and think about there.

Outro


This is it.

Congrats, you made it through UtahJS.

It was great being with you guys today. I think we have time for maybe 3-5 questions.

I’ll stick around a little bit longer after if you want to chat. If not, thanks for coming, and you can find me in these places.

Thank you!