Session Strategies
WebAuthn (Passkey) Strategy

WebAuthn (Passkey) Strategy Strategy

Authenticate users with Web Authentication (opens in a new tab) passkeys and physical tokens. It is implemented using SimpleWebAuthn (opens in a new tab) and supports user authentication and user registration using passkeys.

This package should be considered unstable. It works in my limited testing, but I haven't covered every case or written automated tests. Caveat emptor.

Supported runtimes

RuntimeHas Support
Node.js
Cloudflare

This package also only supports ESM, because package.json is scary and I'm not certain how to set up the necessary build steps. You might need to add this to your serverDependenciesToBundle in your remix.config.js file.

About Web Authentication

Web Authentication lets a user register a device as a passkey. The device could be a USB device, like a Yubikey, the computer running the webpage, or a separate Bluetooth connected device like a smartphone. This page has a good summary of the benefits (opens in a new tab), and you can try it firsthand here (opens in a new tab).

WebAuthn follows a two-step process. First, a device is registered as a passkey. The browser generates a private/public key pair, associates it with a user ID and username, and sends the public key to the server to be verified. At this point the server could create a new user with that passkey, or if the user is already signed in the server could associate that passkey with the existing user.

In the authentication step, the browser uses the passkey's private key to sign a challenge sent by the server, which the server checks with its stored public key in the verification step.

This strategy handles generating the challenge, storing it in session storage, passing the WebAuthn options to the client, generating the passkeys, and verifying the passkeys. Since this strategy requires database persistence and browser-based APIs, it requires a bit more work to set up.

This strategy also requires generating string user IDs on the browser. If your setup requires generating IDs, you might have to work around this limitation by creating a mapping of the authenticator userIds and your actual userIds.

Setup Guide

Database

interface Authenticator {
  // SQL: Encode to base64url then store as `TEXT` or a large `VARCHAR(511)`. Index this column
  credentialID: string;
  // Some reference to the user object. Consider indexing this column too
  userId: string;
  // SQL: Encode to base64url and store as `TEXT`
  credentialPublicKey: string;
  // SQL: Consider `BIGINT` since some authenticators return atomic timestamps as counters
  counter: number;
  // SQL: `VARCHAR(32)` or similar, longest possible value is currently 12 characters
  // Ex: 'singleDevice' | 'multiDevice'
  credentialDeviceType: string;
  // SQL: `BOOL` or whatever similar type is supported
  credentialBackedUp: boolean;
  // SQL: `VARCHAR(255)` and store string array or a CSV string
  // Ex: ['usb' | 'ble' | 'nfc' | 'internal']
  transports: string;
}

Create the strategy instance

This strategy tries not to make assumptions about your database structure, so it requires several configuration options.

authenticator.use(
  new WebAuthnStrategy(
    {
      // The human-readable name of your app
      rpName: "Remix Auth WebAuthn",
      // The hostname of the website, determines where passkeys can be used
      // See https://www.w3.org/TR/webauthn-2/#relying-party-identifier
      rpID: env.NODE_ENV === "development" ? "localhost" : env.APP_URL,
      // Website URL (or array of URLs) where the registration can occur
      origin: env.APP_URL,
      // Return the list of authenticators associated with this user. You might
      // need to transform a CSV string into a list of strings at this step.
      getUserAuthenticators: async (user) => {
        const authenticators = await getAuthenticators(user)
 
        return authenticators.map((authenticator) => ({
          ...authenticator
          transports: authenticator.transports.split(",")
        }));
      },
      // Transform the user object into the shape expected by the strategy.
      // You can use a regular username, the users email address, or something else.
      getUserDetails: (user) => ({ id: user!.id, username: user!.email }),
      // Find a user in the database with their username/email.
      getUserByUsername: (username) => getUserByEmail(username),
    },
    async function verify({ authenticator, type, username }) {
     // ...
    }
  )
);

Write your verify function

The verify function handles both the registration and authentication steps, and expects you to return a user object or throw an error if verification fails.

The verify function will receive an Authenticator object (without the userId), the provided username, and the type of verification - either registration or authentication.

Note: You'll have to implement your own endpoints for adding additional authenticators to existing users.

authenticator.use(
  new WebAuthnStrategy(
    {
      // Options here...
    },
    async function verify({ authenticator, type, username }) {
      let user: User | null = null;
      const savedAuthenticator = await getAuthenticatorById(
        authenticator.credentialID
      );
      if (type === "registration") {
        // Check if the authenticator exists in the database
        if (savedAuthenticator) {
          throw new Error("Authenticator has already been registered.");
        } else {
          // Username is null for authentication verification,
          // but required for registration verification.
          // It is unlikely this error will ever be thrown,
          // but it helps with the TypeScript checking
          if (!username) throw new Error("Username is required.");
          user = await getUserByEmail(username);
 
          // Don't allow someone to register a passkey for
          // someone elses account.
          if (user) throw new Error("User already exists.");
 
          // Create a new user and authenticator
          user = await createUser(username);
          await createAuthenticator(authenticator, user.id);
        }
      } else if (type === "authentication") {
        if (!savedAuthenticator) throw new Error("Authenticator not found");
        user = await getUserById(savedAuthenticator.userId);
      }
 
      if (!user) throw new Error("User not found");
      return user;
    }
  )
);

Set up your login page loader and action

The login page will need a loader to supply the WebAuthn options from the server, and an action to deliver the passkey back to the server.

// /app/routes/_auth.login.ts
export let loader = async ({ request }: LoaderArgs) => {
  await authenticator.isAuthenticated(request, { successRedirect: "/" });
 
  // When we pass a GET request to the authenticator, it will
  // throw a response that includes the WebAuthn options and
  // stores the challenge on session storage. To avoid needing
  // a CatchBoundary, we catch the response here and return it as
  // loader data.
  try {
    await authenticator.authenticate("webauthn", request);
  } catch (response) {
    if (response instanceof Response && response.status === 200) {
      return response;
    }
    throw response;
  }
};
 
export let action = async ({ request }: DataFunctionArgs) => {
  // If you're using multiple authenticator strategies, you can
  // invoke them here based on the form data that was submitted.
  try {
    await authenticator.authenticate("webauthn", request, {
      successRedirect: "/",
    });
  } catch (error) {
    // You can catch the error here and resolve the message
    // for more direct error handling.
    if (error instanceof Response && error.status >= 400) {
      return { error: (await error.json()) as { message: string } };
    }
    throw error;
  }
 
  return null;
};

Set up the form

For ease-of-use, this strategy provides an onSubmit handler which performs the necessary browser-side actions to generate passkeys. The onSubmit handler is generated by passing in the options object from the loader above. Depending on your setup, you might need to implement separate forms for registration and authentication.

When registering, the process follows a few steps:

  1. The user requests registration by entering their desired username and pressing the button, which submits a GET request to get updated options.
  2. The server responds with whether the username is taken and if the user already has registered a passkey so the browser doesn't produce duplicates.
  3. The form must be submitted a second time, as POST this time, with the actual passkey for registration.
  4. The server verifies the passkey, creates the new user, and logs the user in.

Your registration form should include a required username field and a button for registration. The button should change state and behavior based on whether the options from the loader indicate that the username is available. This is demonstrated below.

Authentication is a simpler process and only requires one button press:

  1. The user requests authentication, and the browser shows the available passkeys for the domain.
  2. The user picks a passkey, and the form is generated and submitted to the server.
  3. The server verifies the passkey by checking it against the database, and logs the user in.

Since the username is stored with the passkey in the browser, the username field is not required for the authentication form.

Two hidden form inputs are required on the form. The one named response will have its value dynamically set to the generated passkey after the user goes through the browser's passkey process. The type field should be either registration or authentication, and is also automatically set based on the value attribute of the button which submits the form.

Here's what the forms might look like in practice:

// /app/routes/_auth.login.ts
export default function Login() {
  let actionData = useActionData<Awaited<ReturnType<typeof action>>>();
  let navigationData = useNavigation();
  let options = useLoaderData<WebAuthnOptionsResponse>();
 
  const [usernameAvailable, setUsernameAvailable] = useState(
    options.usernameAvailable
  );
  useEffect(() => {
    setUsernameAvailable(options.usernameAvailable);
  }, [options]);
 
  return (
    <div className="flex flex-col gap-4 max-w-[300px] mx-auto">
      <h1 className="text-2xl font-semibold">Log in to your account.</h1>
      <Form
        method="post"
        className="w-full space-y-2"
        onSubmit={handleFormSubmit(options)(event)}
      >
        {/* These two hidden form inputs are required on both forms */}
        <input type="hidden" name="response" />
        <input type="hidden" name="type" value="registration" />
        <div>
          <Label htmlFor="email">Email address</Label>
          <Input
            id="email"
            type="email"
            name="username"
            required
            placeholder="name@domain.com"
            autoComplete="webauthn username"
            onChange={() => setUsernameAvailable(null)}
            disabled={navigationData.state === "submitting"}
          />
        </div>
 
        <Button
          type="submit"
          name="registration"
          value="registration"
          formMethod={usernameAvailable ? "POST" : "GET"}
          disabled={
            navigationData.state === "submitting" || usernameAvailable === false
          }
          className="w-full"
        >
          {usernameAvailable === null
            ? "Check Username with Passkey"
            : "Sign Up with Passkey"}
        </Button>
        {usernameAvailable === false ? (
          <p className="text-red-600">Username is taken</p>
        ) : usernameAvailable === true ? (
          <p className="text-green-600">Username available</p>
        ) : null}
      </Form>
      <Form
        method="post"
        className="w-full space-y-2"
        onSubmit={handleFormSubmit(options)}
      >
        {/* These two hidden form inputs are required on both forms */}
        <input type="hidden" name="response" />
        <input type="hidden" name="type" value="authentication" />
        <Button
          type="submit"
          name="authentication"
          value="authentication"
          disabled={navigationData.state === "submitting"}
          className="w-full"
        >
          Sign In with Passkey
        </Button>
      </Form>
      {actionData && "error" in actionData ? (
        <p className="text-red-600">{actionData.error?.message}</p>
      ) : null}
    </div>
  );
}