Passkey MFA is a multi-factor authentication (MFA) method that enhances account security by adding passkeys as a second factor to traditional password-based logins.

It ensures that only users with access to both the password and the registered device can successfully authenticate, providing an extra layer of protection against unauthorized access.

This example demonstrates how to implement MFA in Node.js using the @teamhanko/passkeys-sdk. If you’re not using a JS/TS backend, you can achieve the same result by utilizing the Passkey API directly.

This guide showcases how to add passkeys as second factor for password-based logins. If your goal is to set up a passkey-based passwordless login system instead, please refer to this guide and the Passkey API documentation for more suitable instructions.

Install Passkey SDK

Install the Passkey SDK:

Get your tenant ID and API key

Get your tenant ID and API key from Hanko Cloud and add them to your .env file.

.env
PASSKEYS_API_KEY=your-api-key
PASSKEYS_TENANT_ID=your-tenant-id

Allow users to register passkeys for MFA

On your backend, you’ll have to call tenant({ ... }).user(userId).mfa.registration.initialize() and mfa.registration.finalize() to create and store a passkey for your user which will be used as an MFA.

services.js
import { tenant } from "@teamhanko/passkeys-sdk";
import dotenv from "dotenv";
import db from "../db.js";

dotenv.config();

const passkeyApi = tenant({
  apiKey: process.env.PASSKEYS_API_KEY,
  tenantId: process.env.PASSKEYS_TENANT_ID,
});

async function startMfaRegistration(userID) {
  const user = db.users.find((user) => user.id === userID);

  const createOptions = await passkeyApi
    .user(user.id)
    .mfa.registration.initialize({
      username: user.email || "",
    });

  return createOptions;
}

async function finishMfaRegistration(userID, credential) {
  const user = db.users.find((user) => user.id === userID);
  await passkeyApi.user(user.id).mfa.registration.finalize(credential);
}
controllers.js
async function handleMfaRegister(req, res) {
  const { user } = req;
  const userID = user.id;

  if (!userID) {
    return res.status(401).json({ message: "Unauthorized" });
  }

  const { start, finish, credential } = req.body;

  try {
    if (start) {
      const createOptions = await startMfaRegistration(userID);
      return res.json({ createOptions });
    }
    if (finish) {
      await finishMfaRegistration(userID, credential);
      const user = db.users.find((user) => user.id === userID);
      user.mfaEnabled = true;
      return res.json({ message: "Registered MFA" });
    }
  } catch (error) {
    return res.status(500).json(error);
  }
}

Frontend

On your frontend, the registerMfaPasskey() function handles the passkey registration process. It first sends a request to the server to initiate the registration process and receives the response for creating a new passkey.

It then uses the @github/webauthn-json library to create a new passkey credential based on the received options from the response. Finally, it sends another request to the server with the newly created credential to complete the registration process.

async function registerMfaPasskey() {
  const createOptionsResponse = await fetch(
    "http://localhost:5001/api/passkeys/mfa/register",
    {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      credentials: "include",
      body: JSON.stringify({ start: true, finish: false, credential: null }),
    }
  );

  const { createOptions } = await createOptionsResponse.json();
  console.log("createOptions", createOptions);

  const credential = await create(
    createOptions as CredentialCreationOptionsJSON
  );
  console.log(credential);

  const response = await fetch(
    "http://localhost:5001/api/passkeys/mfa/register",
    {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      credentials: "include",
      body: JSON.stringify({ start: false, finish: true, credential }),
    }
  );
  console.log(response);

  if (response.ok) {
    toast.success("Registered MFA passkey successfully!");
    return;
  }
}

Authenticate users with MFA

services.js
async function startMfaLogin(userID) {
  const user = db.users.find((user) => user.id === userID);
  const options = await passkeyApi.user(user.id).mfa.login.initialize();
  return options;
}

async function finishMfaLogin(userID, options) {
  const user = db.users.find((user) => user.id === userID);
  const response = await passkeyApi.user(user.id).mfa.login.finalize(options);
  return response;
}
controllers.js
async function handleMfaLogin(req, res) {
  const { user } = req;
  const userID = user.id;

  if (!userID) {
    return res.status(401).json({ message: "MFA Login not allowed" });
  }
  const { start, finish, options } = req.body;

  try {
    if (start) {
      const loginOptions = await startMfaLogin(userID);
      return res.json({ loginOptions });
    }
    if (finish) {
      const jwtToken = await finishMfaLogin(userID, options);
      const newUserID = await getUserID(jwtToken?.token ?? "");
      const user = db.users.find((user) => user.id === newUserID);
      if (!user) {
        return res.status(401).json({ message: "Invalid user" });
      }
      const sessionId = uuidv4();
      setUser(sessionId, user);
      return res.json({ message: " MFA Passkey Login successful" });
    }
  } catch (error) {
    console.error(error);
    return res
      .status(500)
      .json({ message: "An error occurred during the passkey login process." });
  }
}

Frontend

Feel free to customize the MFA flow based on your app’s requirements. In our example implementation, if mfaRequired=true is received from the login API, the user is redirected to an MFA page where passkey-based multi-factor authentication is performed.

async function mfaLogin() {
  const createOptionsResponse = await fetch(
    "http://localhost:5001/api/passkeys/mfa/login",
    {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      credentials: "include",
      body: JSON.stringify({ start: true, finish: false, credential: null }),
    }
  );

  const { loginOptions } = await createOptionsResponse.json();

  // Open "register passkey" dialog
  const options = await get(loginOptions as any);

  const response = await fetch("http://localhost:5001/api/passkeys/mfa/login", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    credentials: "include",
    body: JSON.stringify({ start: false, finish: true, options }),
  });

  if (response.ok) {
    console.log("user logged in with mfa passkey");
    navigate("/dashboard");
    return;
  }
}

Try it yourself

React-Express Example

Full source code available on our GitHub