Skip to main content
Docs

Build a custom sign-in flow with multi-factor authentication (MFA)

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 applies to the following Clerk SDKs:

  • @clerk/react v6 or higher
  • @clerk/nextjs v7 or higher
  • @clerk/expo v3 or higher
  • @clerk/react-router v3 or higher
  • @clerk/tanstack-react-start v0.26.0 or higher

If you're using an older version of one of these SDKs, or are using the legacy API, refer to the legacy API documentation.

If you have multi-factor authentication (MFA) enabled for your application, the sign-in attempt will return a status of needs_second_factor. Your custom sign-in flow needs to support handling whichever strategy you've enabled in the Clerk Dashboard.

Clerk allows you to enable the following second factor strategies:

  • MFA:
    • SMS verification code
    • Authenticator application
    • Backup codes (but one of the other strategies must be enabled)

This guide will demonstrate how to build a custom user interface for adding MFA to your sign-in flow.

Enable email and password

This example uses the email and password sign-in custom flow as a base. However, you can modify this approach according to the settings you've configured for your application's instance in the Clerk Dashboard.

  1. In the Clerk Dashboard, navigate to the User & authentication page.
  2. Enable Sign-up with email.
    • Require email address should be enabled.
    • For Verify at sign-up, Email verification code is enabled by default, and is used for this guide. If you'd like to use Email verification link instead, see the dedicated custom flow.
  3. Enable Sign in with email.
  4. Select the Password tab and enable Sign-up with password.
    • Client Trust is enabled by default. The sign-in example supports it using email verification codes because it's the default second factor strategy.

Enable multi-factor authentication (MFA)

  1. In the Clerk Dashboard, navigate to the Multi-factor page.
  2. Enable the strategy you want to use for your second factor.
    • To enable SMS verification code, you'll need to enable Sign-up with phone and Sign-in with phone. It's highly recommended to enable Verify at sign-up for phone numbers.
  3. Require multi-factor authentication is enabled by default. You will need to handle the setup-mfa . See the dedicated custom flow for more information.
  4. Select Save.

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.

The following example demonstrates how to build a custom sign-in flow that supports SMS verification codes, authenticator app codes, and backup codes. Essentially, you want to:

  1. Check the signIn.status to see if it's needs_second_factor.
  2. If it is, display a form to collect the MFA code.
  3. If the user submits the form, verify the code with the appropriate method: signIn.mfa.verifyPhoneCode(), signIn.mfa.verifyTOTP(), or signIn.mfa.verifyBackupCode().
  4. If the verification is successful (the signIn.status is 'complete'), call signIn.finalize() to set the newly created session as the active session.

Tip

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

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

import { useSignIn } from '@clerk/nextjs'
import { useRouter } from 'next/navigation'

export default function Page() {
  const { signIn, errors, fetchStatus } = useSignIn()
  const router = useRouter()

  const handleSubmit = async (formData: FormData) => {
    const emailAddress = formData.get('email') as string
    const password = formData.get('password') as string

    await signIn.password({
      emailAddress,
      password,
    })

    if (signIn.status === 'complete') {
      await signIn.finalize({
        navigate: ({ 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 (signIn.status === 'needs_second_factor') {
      await signIn.mfa.sendPhoneCode()
    } else if (signIn.status === 'needs_client_trust') {
      // See https://clerk.com/docs/guides/development/custom-flows/authentication/client-trust
    } else {
      // Check why the sign-in is not complete
      console.error('Sign-in attempt not complete:', signIn)
    }
  }

  const handleMFAVerification = async (formData: FormData) => {
    const code = formData.get('code') as string
    const useBackupCode = formData.get('useBackupCode') === 'on'

    if (useBackupCode) {
      await signIn.mfa.verifyBackupCode({ code })
    } else {
      await signIn.mfa.verifyPhoneCode({ code })
      // If you're using the authenticator app strategy, use the following method instead:
      // await signIn.mfa.verifyTOTP({ code })
    }

    if (signIn.status === 'complete') {
      await signIn.finalize({
        navigate: ({ 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)
          }
        },
      })
    }
  }

  if (signIn.status === 'needs_second_factor') {
    return (
      <div>
        <h1>Verify your account</h1>
        <form action={handleMFAVerification}>
          <div>
            <label htmlFor="code">Code</label>
            <input id="code" name="code" type="text" />
            {errors.fields.code && <p>{errors.fields.code.message}</p>}
          </div>
          <div>
            <label>
              Use backup code
              <input type="checkbox" name="useBackupCode" />
            </label>
          </div>
          <button type="submit" disabled={fetchStatus === 'fetching'}>
            Verify
          </button>
        </form>
      </div>
    )
  }

  return (
    <>
      <h1>Sign in</h1>
      <form action={handleSubmit}>
        <div>
          <label htmlFor="email">Enter email address</label>
          <input id="email" name="email" type="email" />
          {errors.fields.identifier && <p>{errors.fields.identifier.message}</p>}
        </div>
        <div>
          <label htmlFor="password">Enter password</label>
          <input id="password" name="password" type="password" />
          {errors.fields.password && <p>{errors.fields.password.message}</p>}
        </div>
        <button type="submit" disabled={fetchStatus === 'fetching'}>
          Continue
        </button>
      </form>
      {/* For your debugging purposes. You can just console.log errors, but we put them in the UI for convenience */}
      {errors && <p>{JSON.stringify(errors, null, 2)}</p>}
    </>
  )
}
app/(auth)/sign-in.tsx
import { ThemedText } from '@/components/themed-text'
import { ThemedView } from '@/components/themed-view'
import { useSignIn } from '@clerk/expo'
import { type Href, useRouter } from 'expo-router'
import React from 'react'
import { Pressable, StyleSheet, TextInput, View } from 'react-native'

export default function Page() {
  const { signIn, errors, fetchStatus } = useSignIn()
  const router = useRouter()

  const [emailAddress, setEmailAddress] = React.useState('')
  const [password, setPassword] = React.useState('')
  const [code, setCode] = React.useState('')
  const [useBackupCode, setUseBackupCode] = React.useState(false)

  const handleSubmit = async () => {
    await signIn.password({
      emailAddress,
      password,
    })

    if (signIn.status === 'complete') {
      await signIn.finalize({
        navigate: ({ 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 as Href)
          }
        },
      })
    } else if (signIn.status === 'needs_second_factor') {
      await signIn.mfa.sendPhoneCode()
    } else if (signIn.status === 'needs_client_trust') {
      // See https://clerk.com/docs/guides/development/custom-flows/authentication/client-trust
    } else {
      // Check why the sign-in is not complete
      console.error('Sign-in attempt not complete:', signIn)
    }
  }

  const handleMFAVerification = async () => {
    if (useBackupCode) {
      await signIn.mfa.verifyBackupCode({ code })
    } else {
      await signIn.mfa.verifyPhoneCode({ code })
      // If you're using the authenticator app strategy, use the following method instead:
      // await signIn.mfa.verifyTOTP({ code })
    }

    if (signIn.status === 'complete') {
      await signIn.finalize({
        navigate: ({ 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 as Href)
          }
        },
      })
    }
  }

  if (signIn.status === 'needs_second_factor') {
    return (
      <ThemedView style={styles.container}>
        <ThemedText type="title" style={styles.title}>
          Verify your account
        </ThemedText>
        <ThemedText style={styles.label}>Code</ThemedText>
        <TextInput
          style={styles.input}
          value={code}
          placeholder="Enter code"
          placeholderTextColor="#666666"
          onChangeText={(code) => setCode(code)}
          keyboardType="numeric"
        />
        {errors.fields.code && (
          <ThemedText style={styles.error}>{errors.fields.code.message}</ThemedText>
        )}
        <Pressable style={styles.legalRow} onPress={() => setUseBackupCode((v) => !v)}>
          <View style={[styles.checkbox, useBackupCode && styles.checkboxChecked]}>
            {useBackupCode && <ThemedText style={styles.checkmark}>✓</ThemedText>}
          </View>
          <ThemedText style={styles.legalLabel}>Use backup code</ThemedText>
        </Pressable>
        <Pressable
          style={({ pressed }) => [
            styles.button,
            fetchStatus === 'fetching' && styles.buttonDisabled,
            pressed && styles.buttonPressed,
          ]}
          onPress={handleMFAVerification}
          disabled={fetchStatus === 'fetching'}
        >
          <ThemedText style={styles.buttonText}>Verify</ThemedText>
        </Pressable>
      </ThemedView>
    )
  }

  return (
    <ThemedView style={styles.container}>
      <ThemedText type="title" style={styles.title}>
        Sign in
      </ThemedText>
      <ThemedText style={styles.label}>Enter email address</ThemedText>
      <TextInput
        style={styles.input}
        autoCapitalize="none"
        value={emailAddress}
        placeholder="Enter email"
        placeholderTextColor="#666666"
        onChangeText={(emailAddress) => setEmailAddress(emailAddress)}
        keyboardType="email-address"
      />
      {errors.fields.identifier && (
        <ThemedText style={styles.error}>{errors.fields.identifier.message}</ThemedText>
      )}
      <ThemedText style={styles.label}>Enter password</ThemedText>
      <TextInput
        style={styles.input}
        value={password}
        placeholder="Enter password"
        placeholderTextColor="#666666"
        secureTextEntry
        onChangeText={(password) => setPassword(password)}
      />
      {errors.fields.password && (
        <ThemedText style={styles.error}>{errors.fields.password.message}</ThemedText>
      )}
      <Pressable
        style={({ pressed }) => [
          styles.button,
          fetchStatus === 'fetching' && styles.buttonDisabled,
          pressed && styles.buttonPressed,
        ]}
        onPress={handleSubmit}
        disabled={fetchStatus === 'fetching'}
      >
        <ThemedText style={styles.buttonText}>Continue</ThemedText>
      </Pressable>
      {errors && <ThemedText style={styles.debug}>{JSON.stringify(errors, null, 2)}</ThemedText>}
    </ThemedView>
  )
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    padding: 20,
    gap: 12,
  },
  title: {
    marginBottom: 8,
  },
  label: {
    fontWeight: '600',
    fontSize: 14,
  },
  input: {
    borderWidth: 1,
    borderColor: '#ccc',
    borderRadius: 8,
    padding: 12,
    fontSize: 16,
    backgroundColor: '#fff',
  },
  button: {
    backgroundColor: '#0a7ea4',
    paddingVertical: 12,
    paddingHorizontal: 24,
    borderRadius: 8,
    alignItems: 'center',
    marginTop: 8,
  },
  buttonPressed: {
    opacity: 0.7,
  },
  buttonDisabled: {
    opacity: 0.5,
  },
  buttonText: {
    color: '#fff',
    fontWeight: '600',
  },
  legalRow: {
    flexDirection: 'row',
    alignItems: 'center',
    gap: 8,
    marginTop: 4,
  },
  checkbox: {
    width: 22,
    height: 22,
    borderWidth: 2,
    borderColor: '#687076',
    borderRadius: 4,
    alignItems: 'center',
    justifyContent: 'center',
  },
  checkboxChecked: {
    backgroundColor: '#0a7ea4',
    borderColor: '#0a7ea4',
  },
  checkmark: {
    color: '#fff',
    fontSize: 14,
    fontWeight: 'bold',
  },
  legalLabel: {
    fontSize: 14,
    lineHeight: 20,
  },
  error: {
    color: '#d32f2f',
    fontSize: 12,
    marginTop: -8,
  },
  debug: {
    fontSize: 10,
    opacity: 0.5,
    marginTop: 8,
  },
})
MFASignInView.swift
import SwiftUI
import ClerkKit

struct MFASignInView: View {
@Environment(Clerk.self) private var clerk
@State private var email = ""
@State private var password = ""
@State private var code = ""
@State private var displayMFAVerification = false

var body: some View {
  if displayMFAVerification {
    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 sign-in with email/password
    let signIn = try await clerk.auth.signInWithPassword(
      identifier: email,
      password: password
    )

    switch signIn.status {
    // Handle user submitting email and password and swapping to MFA form
    case .needsSecondFactor:
      // If you're using authenticator app strategy, remove the following line
      signIn = try await signIn.sendMfaPhoneCode()
      displayMFAVerification = true
    default:
      // If the status is not needsSecondFactor, check why. User may need to
      // complete further steps
      dump(signIn.status)
    }
  } catch {
    // See https://clerk.com/docs/guides/development/custom-flows/error-handling
    // for more info on error handling
    dump(error)
  }
}

// Verify the MFA code
func verify(code: String) async {
  do {
    guard var signIn = clerk.auth.currentSignIn else { return }

    signIn = try await signIn.verifyMfaCode(code, type: .phoneCode)
    // If you're using the authenticator app strategy, use the following method instead:
    // signIn = try await signIn.verifyMfaCode(code, type: .totp)

    switch signIn.status {
    case .complete:
      dump(clerk.session)
    default:
      dump(signIn.status)
    }
  } catch {
    // See https://clerk.com/docs/guides/development/custom-flows/error-handling
    // for more info on error handling
    dump(error)
  }
}
}
MFASignInViewModel.kt
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.clerk.api.Clerk
import com.clerk.api.network.serialization.onFailure
import com.clerk.api.network.serialization.onSuccess
import com.clerk.api.signin.SignIn
import com.clerk.api.signin.attemptSecondFactor
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch

class MFASignInViewModel : ViewModel() {
    private val _uiState = MutableStateFlow<UiState>(UiState.Unverified)
    val uiState = _uiState.asStateFlow()

    fun submit(email: String, password: String) {
    viewModelScope.launch {
        SignIn.create(SignIn.CreateParams.Strategy.Password(identifier = email, password = password))
        .onSuccess {
            if (it.status == SignIn.Status.NEEDS_SECOND_FACTOR) {
            // If you're using authenticator app strategy, remove the following line
            it = try await it.sendMfaPhoneCode()
            // Display MFA form
            _uiState.value = UiState.NeedsSecondFactor
            } else {
            // If the status is not needsSecondFactor, check why. User may need to
            // complete different steps.
            }
        }
        .onFailure {
            // See https://clerk.com/docs/guides/development/custom-flows/error-handling
            // for more info on error handling
        }
    }
    }

    fun verify(code: String) {
    val inProgressSignIn = Clerk.signIn ?: return
    viewModelScope.launch {
      // If you're using authenticator app strategy, use the following method instead:
      // inProgressSignIn.verifyMfaCode(code, MfaType.TOTP)

      inProgressSignIn.verifyMfaCode(code, MfaType.PHONE_CODE)
        .onSuccess {
            if (it.status == SignIn.Status.COMPLETE) {
                // User is now signed in and verified.
                // You can navigate to the next screen or perform other actions.
            _uiState.value = UiState.Verified
            }
        }
        .onFailure {
            // See https://clerk.com/docs/guides/development/custom-flows/error-handling
            // for more info on error handling
        }
    }
    }

    sealed interface UiState {
    data object Unverified : UiState
    data object Verified : UiState
    data object NeedsSecondFactor : UiState
    }
}
MFASignInActivity.kt
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle

class MFASignInActivity : ComponentActivity() {
  val viewModel: MFASignInViewModel by viewModels()

  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContent {
      val state by viewModel.uiState.collectAsStateWithLifecycle()
      MFASignInView(state = state, onSubmit = viewModel::submit, onVerify = viewModel::verify)
    }
  }
}

@Composable
fun MFASignInView(
  state: MFASignInViewModel.UiState,
  onSubmit: (String, String) -> Unit,
  onVerify: (String) -> Unit,
) {
  var email by remember { mutableStateOf("") }
  var password by remember { mutableStateOf("") }
  var code by remember { mutableStateOf("") }

  Column(
    modifier = Modifier.fillMaxSize(),
    verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically),
    horizontalAlignment = Alignment.CenterHorizontally,
  ) {
    when (state) {
      MFASignInViewModel.UiState.NeedsSecondFactor -> {
        TextField(value = code, onValueChange = { code = it }, placeholder = { Text("Code") })
        Button(onClick = { onVerify(code) }) { Text("Submit") }
      }
      MFASignInViewModel.UiState.Unverified -> {
        TextField(value = email, onValueChange = { email = it }, placeholder = { Text("Email") })
        TextField(
          value = password,
          onValueChange = { password = it },
          placeholder = { Text("Password") },
          visualTransformation = PasswordVisualTransformation(),
        )
        Button(onClick = { onSubmit(email, password) }) { Text("Next") }
      }
      MFASignInViewModel.UiState.Verified -> {
        Text("Verified")
      }
    }
  }
}

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 settings.

Feedback

What did you think of this content?

Last updated on