Skip to main content
Docs

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

Warning

This guide is for users who want to build a . To use a prebuilt UI, use the Account Portal pages or prebuilt components.

Important

This guide uses the Core 2 useSignIn() and useSignUp() hooks, which are available in Core 3 SDKs by adding the /legacy subpath to the import path. If you're using a Core 2 SDK, remove the /legacy subpath.

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

Clerk supports 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

This guide uses email and password to sign in, however, you can modify this approach according to the needs of your application.

To follow this guide, you first need to ensure email and password are enabled for your application.

  1. In the Clerk Dashboard, navigate to the User & authentication page.
  2. Enable Sign-in with email.
  3. Select the Password tab and enable Sign-up with password. Leave Require a password at sign-up enabled.

Enable multi-factor authentication

For your users to be able to enable MFA for their account, you need to enable MFA for your application.

  1. In the Clerk Dashboard, navigate to the Multi-factor page.
  2. For the purpose of this guide, toggle on both the Authenticator application and Backup codes strategies.
  3. Select Save.

Warning

If you're using Duo as an authenticator app, please note that Duo generates TOTP codes differently than other authenticator apps. Duo allows a code to be valid for 30 seconds from the moment it is first displayed, which may cause frequent invalid_code errors if the code is not entered promptly. More information can be found in Duo's Help Center.

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 and verifications are completed.

Warning

For this example to work, the user must have MFA enabled on their account. You need to add the ability for your users to manage their MFA settings. See the dedicated custom flow.

Tip

Examples for this SDK aren't available yet. For now, try adapting the available example to fit your SDK.

app/sign-in/[[...sign-in]]/page.tsx
'use client'

import * as React from 'react'
import { useSignIn } from '@clerk/nextjs/legacy'
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 using the email and password provided
    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,
          navigate: async ({ session, decorateUrl }) => {
            if (session?.currentTask) {
              // Handle pending session tasks
              // See https://clerk.com/docs/guides/development/custom-flows/authentication/session-tasks
              console.log(session?.currentTask)
              return
            }

            const url = decorateUrl('/')
            if (url.startsWith('http')) {
              window.location.href = url
            } else {
              router.push(url)
            }
          },
        })
      } 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/guides/development/custom-flows/error-handling
      // for more info on error handling
      console.error('Error:', JSON.stringify(err, null, 2))
    }
  }

  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" disabled={!email.trim() || !password.trim()}>
          Continue
        </button>
      </form>
    </>
  )
}
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="task"></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>
main.js
import { Clerk } from '@clerk/clerk-js'

const pubKey = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY

const clerk = new Clerk(pubKey)
await clerk.load()

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

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

  clerk.mountUserButton(userbuttonDiv)
} else if (clerk.session?.currentTask) {
  // Handle pending session tasks
  // See https://clerk.com/docs/guides/development/custom-flows/authentication/session-tasks
  switch (clerk.session.currentTask.key) {
    case 'choose-organization': {
      document.getElementById('app').innerHTML = `
            <div id="task"></div>
          `

      const taskDiv = document.getElementById('task')

      clerk.mountTaskChooseOrganization(taskDiv)
    }
  }
} 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/guides/development/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 setActive({
          session: signInAttempt.createdSessionId,
          navigate: async ({ session, decorateUrl }) => {
            if (session?.currentTask) {
              // Handle pending session tasks
              // See https://clerk.com/docs/guides/development/custom-flows/authentication/session-tasks
              console.log(session?.currentTask)
              return
            }

            const url = decorateUrl('/')
            if (url.startsWith('http')) {
              window.location.href = url
            } else {
              router.push(url)
            }
          },
        })
        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/guides/development/custom-flows/error-handling
      // for more info on error handling
      console.error(error)
    }
  })
}

Before you start

Install expo-checkbox for the UI.

terminal
npm install expo-checkbox
terminal
pnpm add expo-checkbox
terminal
yarn add expo-checkbox
terminal
bun add expo-checkbox

In the (auth) group, create a sign-in.tsx file with the following code. The useSignIn() hook is used to create a sign-in flow. The user can sign in using their email and password and will be prompted to verify their account with a code from their authenticator app or with a backup code.

app/(auth)/sign-in.tsx
import React from 'react'
import { useSignIn } from '@clerk/expo/legacy'
import { useRouter } from 'expo-router'
import { Text, TextInput, Button, View } from 'react-native'
import Checkbox from 'expo-checkbox'

export default function Page() {
  const { signIn, setActive, isLoaded } = 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 = async () => {
    if (!isLoaded) return

    // Attempt to sign in using the email and password provided
    try {
      const attemptFirstFactor = await signIn.create({
        identifier: email,
        password,
      })

      // If the sign-in was successful, set the session to active
      // and redirect the user
      if (attemptFirstFactor.status === 'complete') {
        await setActive({
          session: attemptFirstFactor.createdSessionId,
          navigate: async ({ session, decorateUrl }) => {
            if (session?.currentTask) {
              // Handle pending session tasks
              // See https://clerk.com/docs/guides/development/custom-flows/authentication/session-tasks
              console.log(session?.currentTask)
              return
            }

            const url = decorateUrl('/')
            if (url.startsWith('http')) {
              window.location.href = url
            } else {
              router.push(url)
            }
          },
        })
      } else if (attemptFirstFactor.status === 'needs_second_factor') {
        // If the sign-in requires a second factor, display the TOTP form
        setDisplayTOTP(true)
      } else {
        // If the sign-in failed, check why. User might need to
        // complete further steps.
        console.error(JSON.stringify(attemptFirstFactor, null, 2))
      }
    } catch (err) {
      // See https://clerk.com/docs/guides/development/custom-flows/error-handling
      // for more info on error handling
      console.error(JSON.stringify(err, null, 2))
    }
  }

  // Handle the submission of the TOTP or backup code
  const onPressTOTP = React.useCallback(async () => {
    if (!isLoaded) return

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

      // If verification was completed, set the session to active
      // and redirect the user
      if (attemptSecondFactor.status === 'complete') {
        await setActive({
          session: attemptSecondFactor.createdSessionId,
          navigate: async ({ session, decorateUrl }) => {
            if (session?.currentTask) {
              // Handle pending session tasks
              // See https://clerk.com/docs/guides/development/custom-flows/authentication/session-tasks
              console.log(session?.currentTask)
              return
            }

            const url = decorateUrl('/')
            if (url.startsWith('http')) {
              window.location.href = url
            } else {
              router.push(url)
            }
          },
        })
      } else {
        // If the status is not complete, check why. User may need to
        // complete further steps.
        console.error(JSON.stringify(attemptSecondFactor, null, 2))
      }
    } catch (err) {
      // See https://clerk.com/docs/guides/development/custom-flows/error-handling
      // for more info on error handling
      console.error(JSON.stringify(err, null, 2))
    }
  }, [isLoaded, email, password, code, useBackupCode])

  if (displayTOTP) {
    return (
      <View>
        <Text>Verify your account</Text>

        <View>
          <TextInput
            value={code}
            placeholder="Enter the code"
            placeholderTextColor="#666666"
            onChangeText={(c) => setCode(c)}
          />
        </View>
        <View style={{ flexDirection: 'row', alignItems: 'center', gap: 5 }}>
          <Text>Check if this code is a backup code</Text>
          <Checkbox value={useBackupCode} onValueChange={() => setUseBackupCode((prev) => !prev)} />
        </View>
        <Button title="Verify" onPress={onPressTOTP} />
      </View>
    )
  }

  return (
    <View>
      <Text>Sign in</Text>
      <View>
        <TextInput
          value={email}
          placeholder="Enter email"
          placeholderTextColor="#666666"
          onChangeText={(email) => setEmail(email)}
        />
      </View>

      <View>
        <TextInput
          value={password}
          placeholder="Enter password"
          placeholderTextColor="#666666"
          secureTextEntry={true}
          onChangeText={(password) => setPassword(password)}
        />
      </View>

      <Button title="Continue" onPress={handleFirstStage} />
    </View>
  )
}

Next steps

Now that users can sign in with MFA, you need to add the ability for your users to manage their MFA settings. Learn how to build a custom flow for managing MFA.

Feedback

What did you think of this content?

Last updated on