Passkeys MFA is still in close beta. Please contact us to request access to enable this feature for your app.

Introduction

This guide will show you how to add a passkey for MFA to your app using your own custom UI. You can also refer to the general MFA guide here to learn more about this feature (note that this guide is for account level MFA, rather than transaction level MFA).

General Flow

When user already has a passkey registered

  1. User logs in
  2. User is redirected to the MFA view
  3. User adds a passkey
  4. User is redirected to the recovery codes view
  5. User acknowledges the recovery codes

The user must acknowledge the recovery codes before the MFA setup flow is complete.

When user does not have a passkey registered

  1. User logs in
  2. User is redirected to the MFA view
  3. User selects existing passkey to authenticate

User does not need to acknowledge the recovery codes again in this flow.

Implementation 1: using our useSyncMfaFlow hook

We provide a hook that will use a provided handler to automatically trigger the MFA flow when required.

import {
  useDynamicContext,
  useSyncMfaFlow,
  useRegisterPasskey,
  useAuthenticatePasskeyMFA,
  useMfa,
  useGetPasskeys,
} from "@dynamic-labs/sdk-react-core";

export const MfaComponent = () => {
  const { userWithMissingInfo } = useDynamicContext();

  const { getRecoveryCodes } = useMfa();
  const getPasskeys = useGetPasskeys();
  const registerPasskey = useRegisterPasskey();
  const authenticatePasskeyMFA = useAuthenticatePasskeyMFA();

  useSyncMfaFlow({
    // this handler will run in one of these three scenarios:
    // 1. when user is required to register a passkey on login
    // 2. when user has a registered passkey and is required to authenticate after login
    // 3. when user is still pending recovery codes acknowledgement
    handler: async () => {
      // this checks if the user is pending registration or authentication of a passkey
      if (userWithMissingInfo?.scope?.includes("requiresAdditionalAuth")) {
        getPasskeys().then(async (passkeys) => {
          // if the user has no passkeys, we need to register a new one
          if (passkeys.length === 0) {
            // here you can show a view to display while the user is prompted to register a passkey
            // e.g. setView('passkey-registration')

            await registerPasskey();
          } else {
            // if the user has a passkey, we need to authenticate it

            // here you can show a view to display while the user is prompted to authenticate their passkey
            // e.g. setView('passkey-authentication')

            await authenticatePasskeyMFA({
              createMfaToken: {
                singleUse: true,
              },
            });
          }
        });
      } else {
        // if the user already has a passkey registered and authenticated,
        // but is pending recovery codes acknowledgement

        const recoveryCodes = await getRecoveryCodes();

        // here you can show a view to display the recovery codes with a button to acknowledge them
        // to acknowledge the recovery codes, you can call the `completeAcknowledgement()` function
        // e.g. setView('recovery-codes', { recoveryCodes, onAck: completeAcknowledgement })
      }
    },
  });
};

Implementation 2: you decide when to trigger each step of the MFA flow

If you set MFA as required for your environment, the user wonโ€™t be considered fully authenticated until they have completed the entire MFA flow.

import { UserPasskey } from "@dynamic-labs/sdk-api-core";
import {
  useAuthenticatePasskeyMFA,
  useGetPasskeys,
  useIsLoggedIn,
  useMfa,
  useRegisterPasskey,
} from "@dynamic-labs/sdk-react-core";
import { useCallback, useEffect, useState } from "react";

export const MfaComponent = () => {
  // when the user is pending any MFA action, Dynamic will set `userWithMissingInfo`
  // other wise it will be undefined and `user` will be defined instead
  const isLogged = useIsLoggedIn();

  const { getRecoveryCodes, completeAcknowledgement } = useMfa();
  const getPasskeys = useGetPasskeys();
  const registerPasskey = useRegisterPasskey();
  const authenticatePasskeyMFA = useAuthenticatePasskeyMFA();

  const [error, setError] = useState<string>();

  const [userPasskeys, setUserPasskeys] = useState<UserPasskey[]>([]);
  const [recoveryCodes, setRecoveryCodes] = useState<string[]>([]);

  // refresh the user's passkeys
  const refreshUserPasskeys = useCallback(async () => {
    const passkeys = await getPasskeys();
    setUserPasskeys(passkeys);
  }, [getPasskeys]);

  // fetch passkeys when the user is logged in
  // so you can display them in your UI
  useEffect(() => {
    if (isLogged) {
      refreshUserPasskeys();
    }
  }, [isLogged, refreshUserPasskeys]);

  // register a new passkey
  const handleRegisterPasskey = async () => {
    // here you can show a view to display while the user is prompted to register a passkey
    // e.g. setView('passkey-registration')

    try {
      await registerPasskey();

      // if you want to refresh the user's passkeys after registering a new one
      refreshUserPasskeys();
    } catch (e) {
      setError(e instanceof Error ? e.message : String(e));
    }
  };

  const handleAuthenticatePasskey = async () => {
    // here you can show a view to display while the user is prompted to authenticate their passkey
    // e.g. setView('passkey-authentication')

    try {
      // if you pass in a createMfaToken option as shown below,
      // `authenticatePasskeyMFA` will return a single use MFA token
      const mfaAuthToken = await authenticatePasskeyMFA({
        createMfaToken: {
          singleUse: true,
        },
      });

      console.log("mfaAuthToken", mfaAuthToken);
    } catch (e) {
      setError(e instanceof Error ? e.message : String(e));
    }
  };

  const handleRecoveryCodes = async () => {
    try {
      const recoveryCodes = await getRecoveryCodes();

      // here you can show a view to display the recovery codes with a button to acknowledge them
      // to acknowledge the recovery codes, you can call the `completeAcknowledgement()` function
      // e.g. setView('recovery-codes', { recoveryCodes, onAck: completeAcknowledgement })
    } catch (e) {
      setError(e instanceof Error ? e.message : String(e));
    }
  };

  return (
    <div className="headless-mfa-view">
      <div className="headless-mfa-view__section">
        <p>
          <b>Passkeys</b>
        </p>
        {userPasskeys.map((passkey) => (
          <div key={passkey.id}>
            <p>Id: {passkey.id}</p>
            <p>Alias: {passkey.alias}</p>
            <p>Credential Id: {passkey.credentialId}</p>
            <p>Origin: {passkey.origin}</p>
            <p>Storage: {passkey.storage?.name}</p>
            <p>Created At: {new Date(passkey.createdAt).toLocaleString()}</p>
            <p>
              Updated At:{" "}
              {passkey.updatedAt
                ? new Date(passkey.updatedAt).toLocaleString()
                : "N/A"}
            </p>
          </div>
        ))}
      </div>

      {error && <div className="headless-mfa__section error">{error}</div>}
      {recoveryCodes && (
        <div className="headless-mfa-view__section">
          <p className="headless-mfa-view__codes">
            Recovery Codes: {recoveryCodes.join(", ")}
          </p>
        </div>
      )}

      <div className="headless-mfa-view__section">
        <button onClick={() => handleRegisterPasskey()}>
          Register Passkey
        </button>
      </div>
      <div className="headless-mfa-view__section">
        <button onClick={() => handleAuthenticatePasskey()}>
          Authenticate Passkey
        </button>
      </div>
      <div className="headless-mfa-view__section">
        <button onClick={() => handleRecoveryCodes()}>
          Generate Recovery Codes
        </button>
      </div>
    </div>
  );
};