Docs

Build a custom sign-in flow with multi-factor authentication

Caution

This guide is for users who want to build a custom user interface using the Clerk API. To sign in users with multi-factor authentication (MFA) using a prebuilt UI, you should use Clerk's Account Portal pages or prebuilt components.

Multi-factor verification (MFA) is an added layer of security that requires users to provide a second verification factor to access an account.

Clerk supports second factor verification through SMS verification code, Authenticator application, and Backup codes.

This guide will walk you through how to build a custom email/password sign-in flow that supports Authenticator application and Backup codes as the second factor.

Enable email and password authentication

To use email and password authentication, you first need to enable these authentication strategies in the Clerk Dashboard.

  1. Navigate to the Clerk Dashboard.
  2. Go to User & Authentication > Email, Phone, and Username in the sidebar menu.
  3. In the Contact information section, ensure that Email address is enabled.
  4. In the Username section, ensure that Username is not required, or else the create() method will require a username to be passed in the params. If you would like to use usernames, you must handle collecting the username in your custom flow.
  5. In the Authentication strategies section of this page, ensure Password is enabled.

Enable multi-factor authentication

For your users to be able to enable MFA for their account, you need to enable MFA as an authentication strategy in your Clerk application.

  1. Navigate to your Clerk Dashboard.
  2. Select your application, then select User & Authentication > Multi-factor in the sidebar menu.
  3. For the sake of this guide, toggle on the Authenticator application and Backup codes strategy.

Create the multi-factor sign-in flow

Signing in to an MFA-enabled account is identical to the regular sign-in process. However, in the case of an MFA-enabled account, a sign-in won't convert until both first factor and second factor verifications are completed.

To authenticate a user using their email and password, you need to:

  1. Initiate the sign-in process by collecting the user's authentication identifier.
  2. Prepare the first factor verification.
  3. Attempt to complete the first factor verification.
  4. Prepare the second factor verification. (This is where MFA comes into play.)
  5. Attempt to complete the second factor verification.
  6. If the verification is successful, set the newly created session as the active session.
'use client';

import * as React from 'react';
import { useSignIn } from '@clerk/nextjs';
import { useRouter } from 'next/navigation';

export default function SignInForm() {
  const { isLoaded, signIn, setActive } = useSignIn();
  const [email, setEmail] = React.useState('');
  const [password, setPassword] = React.useState('');
  const [code, setCode] = React.useState('');
  const [useBackupCode, setUseBackupCode] = React.useState(false);
  const [displayTOTP, setDisplayTOTP] = React.useState(false);
  const router = useRouter();

  // Handle user submitting email and pass and swapping to TOTP form
  const handleFirstStage = (e: React.FormEvent) => {
    e.preventDefault();
    setDisplayTOTP(true);
  };

  // Handle the submission of the TOTP of Backup Code submission
  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();

    if (!isLoaded) return;

    // Start the sign-in process
    try {
      await signIn.create({
        identifier: email,
        password,
      });

      // Attempt the TOTP or backup code verification
      const signInAttempt = await signIn.attemptSecondFactor({
        strategy: useBackupCode ? 'backup_code' : 'totp',
        code: code,
      });

      // If verification was completed, set the session to active
      // and redirect the user
      if (signInAttempt.status === 'complete') {
        await setActive({ session: signInAttempt.createdSessionId });

        router.push('/');
      } else {
        // If the status is not complete, check why. User may need to
        // complete further steps.
        console.log(signInAttempt);
      }
    } catch (err) {
      // See https://clerk.com/docs/custom-flows/error-handling
      // for more info on error handling
      console.error(err);
    }
  };

  if (displayTOTP) {
    return (
      <div>
        <h1>Verify your account</h1>
        <form onSubmit={(e) => handleSubmit(e)}>
          <div>
            <label htmlFor="code">Code</label>
            <input
              onChange={(e) => setCode(e.target.value)}
              id="code"
              name="code"
              type="text"
              value={code}
            />
          </div>
          <div>
            <label htmlFor="backupcode">This code is a backup code</label>
            <input
              onChange={() => setUseBackupCode((prev) => !prev)}
              id="backupcode"
              name="backupcode"
              type="checkbox"
              checked={useBackupCode}
            />
          </div>
          <button type="submit">Verify</button>
        </form>
      </div>
    );
  }

  return (
    <>
      <h1>Sign in</h1>
      <form onSubmit={(e) => handleFirstStage(e)}>
        <div>
          <label htmlFor="email">Email</label>
          <input
            onChange={(e) => setEmail(e.target.value)}
            id="email"
            name="email"
            type="email"
            value={email}
          />
        </div>
        <div>
          <label htmlFor="password">Password</label>
          <input
            onChange={(e) => setPassword(e.target.value)}
            id="password"
            name="password"
            type="password"
            value={password}
          />
        </div>
        <button type="submit">Next</button>
      </form>
    </>
  );
}

For the following example, your HTML file should look like this:

index.html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Clerk + JavaScript App</title>
  </head>
  <body>
    <div id="signed-in"></div>

    <div id="sign-in">
      <h2>Sign in</h2>
      <form id="sign-in-form">
        <label for="email">Enter email address</label>
        <input name="email" id="sign-in-email" />
        <label for="password">Enter password</label>
        <input name="password" id="sign-in-password" />
        <button type="submit">Continue</button>
      </form>
    </div>

    <form id="verifying" hidden>
      <h2>Verify your account</h2>
      <label for="totp">Enter your code</label>
      <input id="totp" name="code" />
      <label for="backupCode">This code is a backup code</label>
      <input type="checkbox" id="backupCode" name="backupCode" />
      <button type="submit" id="verify-button">Verify</button>
    </form>

    <script
      type="module"
      src="/src/main.js"
      async
      crossorigin="anonymous"
    ></script>
  </body>
</html>

And your JavaScript file should look like this:

main.js
import { Clerk } from '@clerk/clerk-js';

// Initialize Clerk with your Clerk publishable key
const clerk = new Clerk('YOUR_PUBLISHABLE_KEY');
await clerk.load();

if (clerk.user) {
  // Mount user button component
  document.getElementById('signed-in').innerHTML = `
    <div id="user-button"></div>
  `;

  const userbuttonDiv = document.getElementById('user-button');

  clerk.mountUserButton(userbuttonDiv);
} else {
  // Handle the sign-in form
  document
    .getElementById('sign-in-form')
    .addEventListener('submit', async (e) => {
      e.preventDefault();

      const formData = new FormData(e.target);
      const emailAddress = formData.get('email');
      const password = formData.get('password');

      try {
        // Start the sign-in process
        await clerk.client.signIn.create({
          identifier: emailAddress,
          password,
        });

        // Hide sign-in form
        document.getElementById('sign-in').setAttribute('hidden', '');
        // Show verification form
        document.getElementById('verifying').removeAttribute('hidden');
      } catch (error) {
        // See https://clerk.com/docs/custom-flows/error-handling
        // for more info on error handling
        console.error(error);
      }
    });

  // Handle the verification form
  document.getElementById('verifying').addEventListener('submit', async (e) => {
    const formData = new FormData(e.target);
    const totp = formData.get('totp');
    const backupCode = formData.get('backupCode');

    try {
      const useBackupCode = backupCode ? true : false;
      const code = backupCode ? backupCode : totp;

      // Attempt the TOTP or backup code verification
      const signInAttempt = await clerk.client.signIn.attemptSecondFactor({
        strategy: useBackupCode ? 'backup_code' : 'totp',
        code: code,
      });

      // If verification was completed, set the session to active
      // and redirect the user
      if (signInAttempt.status === 'complete') {
        await clerk.setActive({ session: signInAttempt.createdSessionId });

        location.reload();
      } else {
        // If the status is not complete, check why. User may need to
        // complete further steps.
        console.error(signInAttempt);
      }
    } catch (error) {
      // See https://clerk.com/docs/custom-flows/error-handling
      // for more info on error handling
      console.error(error);
    }
  });
}
index.html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Clerk + JavaScript App</title>
  </head>
  <body>
    <div id="signed-in"></div>

    <div id="sign-in">
      <h2>Sign in</h2>
      <form id="sign-in-form">
        <label for="email">Enter email address</label>
        <input name="email" id="sign-in-email" />
        <label for="password">Enter password</label>
        <input name="password" id="sign-in-password" />
        <button type="submit">Continue</button>
      </form>
    </div>

    <form id="verifying" hidden>
      <h2>Verify your account</h2>
      <label for="totp">Enter your code</label>
      <input id="totp" name="code" />
      <label for="backupCode">This code is a backup code</label>
      <input type="checkbox" id="backupCode" name="backupCode" />
      <button type="submit" id="verify-button">Verify</button>
    </form>

    <script
      async
      crossorigin="anonymous"
      data-clerk-publishable-key="YOUR_PUBLISHABLE_KEY"
      src="https://YOUR_FRONTEND_API_URL/npm/@clerk/clerk-js@latest/dist/clerk.browser.js"
      type="text/javascript"
    ></script>

    <script>
      window.addEventListener('load', async function () {
        await Clerk.load();

        if (Clerk.user) {
          // Mount user button component
          document.getElementById('signed-in').innerHTML = `
    <div id="user-button"></div>
  `;

          const userbuttonDiv = document.getElementById('user-button');

          Clerk.mountUserButton(userbuttonDiv);
        } else {
          // Handle the sign-in form
          document
            .getElementById('sign-in-form')
            .addEventListener('submit', async (e) => {
              e.preventDefault();

              const formData = new FormData(e.target);
              const emailAddress = formData.get('email');
              const password = formData.get('password');

              try {
                // Start the sign-in process
                await Clerk.client.signIn.create({
                  identifier: emailAddress,
                  password,
                });

                // Hide sign-in form
                document.getElementById('sign-in').setAttribute('hidden', '');
                // Show verification form
                document.getElementById('verifying').removeAttribute('hidden');
              } catch (error) {
                // See https://clerk.com/docs/custom-flows/error-handling
                // for more info on error handling
                console.error(error);
              }
            });

          // Handle the verification form
          document
            .getElementById('verifying')
            .addEventListener('submit', async (e) => {
              const formData = new FormData(e.target);
              const totp = formData.get('totp');
              const backupCode = formData.get('backupCode');

              try {
                const useBackupCode = backupCode ? true : false;
                const code = backupCode ? backupCode : totp;

                // Attempt the TOTP or backup code verification
                const signInAttempt =
                  await Clerk.client.signIn.attemptSecondFactor({
                    strategy: useBackupCode ? 'backup_code' : 'totp',
                    code: code,
                  });

                // If verification was completed, set the session to active
                // and redirect the user
                if (signInAttempt.status === 'complete') {
                  await Clerk.setActive({
                    session: signInAttempt.createdSessionId,
                  });

                  location.reload();
                } else {
                  // If the status is not complete, check why. User may need to
                  // complete further steps.
                  console.error(signInAttempt);
                }
              } catch (error) {
                // See https://clerk.com/docs/custom-flows/error-handling
                // for more info on error handling
                console.error(error);
              }
            });
        }
      });
    </script>
  </body>
</html>

Feedback

What did you think of this content?