Build a custom sign-in flow with multi-factor authentication
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 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.
- In the Clerk Dashboard, navigate to the User & authentication page.
- Enable Sign-in with email.
- 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.
- In the Clerk Dashboard, navigate to the Multi-factor page.
- For the purpose of this guide, toggle on both the Authenticator application and Backup codes strategies.
- 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 and verifications are completed.
This example is written for Next.js App Router but it can be adapted for any React-based framework, such as React Router or Tanstack React Start.
'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,
navigate: async ({ session }) => {
if (session?.currentTask) {
// Handle pending session tasks
// See https://clerk.com/docs/guides/development/custom-flows/authentication/session-tasks
console.log(session?.currentTask)
return
}
await 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/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>
</>
)
}<!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>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 }) => {
if (session?.currentTask) {
// Handle pending session tasks
// See https://clerk.com/docs/guides/development/custom-flows/authentication/session-tasks
console.log(session?.currentTask)
return
}
await router.push('/')
},
})
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.
npm install expo-checkboxpnpm add expo-checkboxyarn add expo-checkboxbun add expo-checkboxIn 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.
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,
navigate: async ({ session }) => {
if (session?.currentTask) {
// Handle pending session tasks
// See https://clerk.com/docs/guides/development/custom-flows/authentication/session-tasks
console.log(session?.currentTask)
return
}
await router.push('/')
},
})
} 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 }) => {
if (session?.currentTask) {
// Handle pending session tasks
// See https://clerk.com/docs/guides/development/custom-flows/authentication/session-tasks
console.log(session?.currentTask)
return
}
await router.push('/')
},
})
} 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>
)
} 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 displayAuthenticatorCode = false
var body: some View {
if displayAuthenticatorCode {
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 TOTP form
case .needsSecondFactor:
displayAuthenticatorCode = true
// If the status is not needsSecondFactor, check why. User may need to
// complete further steps
default:
dump(signIn.status)
}
} catch {
// See https://clerk.com/docs/guides/development/custom-flows/error-handling
// for more info on error handling
dump(error)
}
}
func verify(code: String) async {
do {
// Verify the authenticator app code
guard var signIn = clerk.auth.currentSignIn else { return }
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) {
// Display TOTP 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 {
inProgressSignIn
.attemptSecondFactor(SignIn.AttemptSecondFactorParams.TOTP(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")
}
}
}
}Examples for this SDK aren't available yet. For now, try switching to a supported SDK, such as Next.js, and converting the code to fit your SDK.
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
Last updated on