Build a custom sign-in flow with multi-factor authentication
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.
- 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.
'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>
</>
)
}<!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, 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.
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/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
Last updated on