Docs

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

Warning

This guide is for users who want to build a custom user interface using the Clerk API. To use a prebuilt UI, use the 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

This example uses email and password authentication, however, you can modify this approach according to the needs of your application. To use email and password authentication, you first need to enable these authentication strategies in the Clerk Dashboard.

  1. In the Clerk Dashboard, navigate to the Email, phone, username page.
  2. Ensure that only Email address is required. If Phone number and Username are enabled, ensure they are not required. Use the settings icon next to each user attribute to check if a setting is required or optional. If you want to require Username, you must collect the username and pass the data to the create() method in your custom flow.
  3. 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. 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.

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 email address and password.
  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.

Tip

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 manage SMS-based MFA or the manage TOTP-based MFA guide, depending on your needs.

app/sign-in/[[...sign-in]]/page.tsx
'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 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 })
        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('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">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="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.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)
    }
  })
}

Before you start

Install expo-checkbox for the UI.

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

Build the flow

  1. Create the (auth) route group. This groups your sign-up and sign-in pages.
  2. In the (auth) group, create a _layout.tsx file with the following code. The useAuth() hook is used to access the user's authentication state. If the user's already signed in, they'll be redirected to the home page.
app/(auth)/_layout.tsx
import { Redirect, Stack } from 'expo-router'
import { useAuth } from '@clerk/clerk-expo'

export default function AuthenticatedLayout() {
  const { isSignedIn } = useAuth()

  if (isSignedIn) {
    return <Redirect href={'/'} />
  }

  return <Stack />
}

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/clerk-expo'
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 })
        router.replace('/')
      } 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/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 })

        router.replace('/')
      } 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/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>
  )
}
MFASignInView.swift
import SwiftUI
import ClerkSDK

struct MFASignInView: View {
  @State private var email = ""
  @State private var password = ""
  @State private var code = ""
  @State private var displayTOTP = false

  var body: some View {
    if displayTOTP {
      TextField("Code", text: $code)
      Button("Verify") {
        Task { await verify(code: code) }
      }
    } else {
      TextField("Email", text: $email)
      SecureField("Password", text: $password)
      Button("Next") {
        Task { await submit(email: email, password: password) }
      }
    }
  }
}

extension MFASignInView {

  func submit(email: String, password: String) async {
    do {
      // Start the sign-in process.
      let signIn = try await SignIn.create(strategy: .identifier(email, password: password))

      switch signIn.status {
      case .needsSecondFactor:
        // Handle user submitting email and password and swapping to TOTP form.
        displayTOTP = true
      default:
        // If the status is not needsSecondFactor, check why. User may need to
        // complete different steps.
        dump(signIn.status)
      }
    } catch {
      // See https://clerk.com/docs/custom-flows/error-handling
      // for more info on error handling
      dump(error)
    }
  }

  func verify(code: String) async {
    do {
      // Access the in progress sign in stored on the client object.
      guard let inProgressSignIn = Clerk.shared.client?.signIn else { return }

      // Attempt the TOTP or backup code verification.
      let signIn = try await inProgressSignIn.attemptSecondFactor(for: .totp(code: code))

      switch signIn.status {
      case .complete:
        // If sign-in process is complete, navigate the user as needed.
        dump(Clerk.shared.session)
      default:
        // If the status is not complete, check why. User may need to
        // complete further steps.
        dump(signIn.status)
      }
    } catch {
      // See https://clerk.com/docs/custom-flows/error-handling
      // for more info on error handling
      dump(error)
    }
  }
}

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 TOTP MFA or for managing SMS MFA.

Feedback

What did you think of this content?

Last updated on