Build a custom sign-in flow with multi-factor authentication (MFA)
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.
- In the Clerk Dashboard, navigate to the User & authentication page.
- 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.
- Enable Sign in with email.
- This guide supports password authentication. If you'd like to build a custom flow that allows users to sign in passwordlessly, see the email code custom flow or the email links custom flow.
- 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)
- In the Clerk Dashboard, navigate to the Multi-factor page.
- 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.
- Require multi-factor authentication is enabled by default. You will need to handle the
setup-mfa. See the dedicated custom flow for more information. - Select Save.
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:
- Check the
signIn.statusto see if it'sneeds_second_factor. - If it is, display a form to collect the MFA code.
- If the user submits the form, verify the code with the appropriate method:
signIn.mfa.verifyPhoneCode(),signIn.mfa.verifyTOTP(), orsignIn.mfa.verifyBackupCode(). - If the verification is successful (the
signIn.statusis'complete'), callsignIn.finalize()to set the newly created session as the active session.
'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>}
</>
)
}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,
},
})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)
}
}
}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
}
}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
Last updated on