Build a custom sign-in flow with client trust
If you have Client Trust enabled for your application, when a user is signing in with a password on a new client (e.g. device), the sign-in attempt will return a status of needs_second_factor. Your custom sign-in flow needs to support handling either an email code or SMS code, depending on the settings you've enabled in the Clerk Dashboard.
If Client Trust and MFA are enabled, MFA will take precedence and the sign-in attempt will return a status of needs_second_factor. See the MFA custom flow guide for more information.
Configure application settings
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. This is the option you'll want for this example.
- Enable Sign in with email.
- Select the Password tab and enable Sign-up with password.
Build the custom flow
'use client'
import { useSignIn } from '@clerk/nextjs'
import { EmailCodeFactor } from '@clerk/nextjs/types'
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') {
// See https://clerk.com/docs/guides/development/custom-flows/authentication/multi-factor-authentication
} else if (signIn.status === 'needs_client_trust') {
const emailCodeFactor = signIn.supportedSecondFactors.find(
(factor): factor is EmailCodeFactor => factor.strategy === 'email_code',
)
if (emailCodeFactor) {
await signIn.mfa.sendEmailCode()
}
} else {
// Check why the sign-in is not complete
console.error('Sign-in attempt not complete:', signIn)
}
}
const handleVerify = async (formData: FormData) => {
const code = formData.get('code') as string
await signIn.mfa.verifyEmailCode({ 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_client_trust') {
return (
<>
<h1>Verify your account</h1>
<form action={handleVerify}>
<div>
<label htmlFor="code">Code</label>
<input id="code" name="code" type="text" />
{errors.fields.code && <p>{errors.fields.code.message}</p>}
</div>
<button type="submit" disabled={fetchStatus === 'fetching'}>
Verify
</button>
</form>
<button onClick={() => signIn.mfa.sendEmailCode()}>I need a new code</button>
<button onClick={() => signIn.reset()}>Start over</button>
{/* 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>}
</>
)
}
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, Link, 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 handleSubmit = async () => {
const { error } = await signIn.password({
emailAddress,
password,
})
if (error) {
console.error(JSON.stringify(error, null, 2))
return
}
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') {
// See https://clerk.com/docs/guides/development/custom-flows/authentication/multi-factor-authentication
} else if (signIn.status === 'needs_client_trust') {
const emailCodeFactor = signIn.supportedSecondFactors.find(
(factor): factor is EmailCodeFactor => factor.strategy === 'email_code',
)
if (emailCodeFactor) {
await signIn.mfa.sendEmailCode()
}
} else {
// Check why the sign-in is not complete
console.error('Sign-in attempt not complete:', signIn)
}
}
const handleVerify = async () => {
await signIn.mfa.verifyEmailCode({ 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)
}
},
})
} else {
// Check why the sign-in is not complete
console.error('Sign-in attempt not complete:', signIn)
}
}
if (signIn.status === 'needs_client_trust') {
return (
<ThemedView style={styles.container}>
<ThemedText type="title" style={styles.title}>
Verify your account
</ThemedText>
<TextInput
style={styles.input}
value={code}
placeholder="Enter your verification code"
placeholderTextColor="#666666"
onChangeText={(code) => setCode(code)}
keyboardType="numeric"
/>
{errors.fields.code && (
<ThemedText style={styles.error}>{errors.fields.code.message}</ThemedText>
)}
<Pressable
style={({ pressed }) => [
styles.button,
fetchStatus === 'fetching' && styles.buttonDisabled,
pressed && styles.buttonPressed,
]}
onPress={handleVerify}
disabled={fetchStatus === 'fetching'}
>
<ThemedText style={styles.buttonText}>Verify</ThemedText>
</Pressable>
<Pressable
style={({ pressed }) => [styles.secondaryButton, pressed && styles.buttonPressed]}
onPress={() => signIn.mfa.sendEmailCode()}
>
<ThemedText style={styles.secondaryButtonText}>I need a new code</ThemedText>
</Pressable>
</ThemedView>
)
}
return (
<ThemedView style={styles.container}>
<ThemedText type="title" style={styles.title}>
Sign in
</ThemedText>
<ThemedText style={styles.label}>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}>Password</ThemedText>
<TextInput
style={styles.input}
value={password}
placeholder="Enter password"
placeholderTextColor="#666666"
secureTextEntry={true}
onChangeText={(password) => setPassword(password)}
/>
{errors.fields.password && (
<ThemedText style={styles.error}>{errors.fields.password.message}</ThemedText>
)}
<Pressable
style={({ pressed }) => [
styles.button,
(!emailAddress || !password || fetchStatus === 'fetching') && styles.buttonDisabled,
pressed && styles.buttonPressed,
]}
onPress={handleSubmit}
disabled={!emailAddress || !password || fetchStatus === 'fetching'}
>
<ThemedText style={styles.buttonText}>Continue</ThemedText>
</Pressable>
{/* For your debugging purposes. You can just console.log errors, but we put them in the UI for convenience */}
{errors && <ThemedText style={styles.debug}>{JSON.stringify(errors, null, 2)}</ThemedText>}
<View style={styles.linkContainer}>
<ThemedText>Don't have an account? </ThemedText>
<Link href="/sign-up">
<ThemedText type="link">Sign up</ThemedText>
</Link>
</View>
</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',
},
secondaryButton: {
paddingVertical: 12,
paddingHorizontal: 24,
borderRadius: 8,
alignItems: 'center',
marginTop: 8,
},
secondaryButtonText: {
color: '#0a7ea4',
fontWeight: '600',
},
linkContainer: {
flexDirection: 'row',
gap: 4,
marginTop: 12,
alignItems: 'center',
},
error: {
color: '#d32f2f',
fontSize: 12,
marginTop: -8,
},
debug: {
fontSize: 10,
opacity: 0.5,
marginTop: 8,
},
})Configure application settings
Client Trust will fallback to SMS verification code only if email is completely disabled for the application, or else Client Trust will fallback to either email code or email link, depending on your application's settings.
This example uses the SMS OTP 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.
- Ensure all email options are disabled.
- Select the Phone tab. Ensure all the settings are enabled.
- Select the Password tab and enable Sign-up with password. Client Trust is enabled by default.
Build the custom flow
'use client'
import { useSignIn } from '@clerk/nextjs'
import { PhoneCodeFactor } from '@clerk/nextjs/types'
import { useRouter } from 'next/navigation'
export default function Page() {
const { signIn, errors, fetchStatus } = useSignIn()
const router = useRouter()
const handleSubmit = async (formData: FormData) => {
const phoneNumber = formData.get('phoneNumber') as string
const password = formData.get('password') as string
await signIn.password({
phoneNumber,
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') {
// See https://clerk.com/docs/guides/development/custom-flows/authentication/multi-factor-authentication
} else if (signIn.status === 'needs_client_trust') {
const phoneCodeFactor = signIn.supportedSecondFactors.find(
(factor): factor is PhoneCodeFactor => factor.strategy === 'phone_code',
)
if (phoneCodeFactor) {
await signIn.mfa.sendPhoneCode()
}
} else {
// Check why the sign-in is not complete
console.error('Sign-in attempt not complete:', signIn)
}
}
const handleVerify = async (formData: FormData) => {
const code = formData.get('code') as string
await signIn.mfa.verifyPhoneCode({ 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_client_trust') {
return (
<>
<h1>Verify your account</h1>
<form action={handleVerify}>
<div>
<label htmlFor="code">Code</label>
<input id="code" name="code" type="text" />
{errors.fields.code && <p>{errors.fields.code.message}</p>}
</div>
<button type="submit" disabled={fetchStatus === 'fetching'}>
Verify
</button>
</form>
<button onClick={() => signIn.mfa.sendPhoneCode()}>I need a new code</button>
<button onClick={() => signIn.reset()}>Start over</button>
{/* 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>}
</>
)
}
return (
<>
<h1>Sign in</h1>
<form action={handleSubmit}>
<div>
<label htmlFor="phoneNumber">Enter phone number</label>
<input id="phoneNumber" name="phoneNumber" type="tel" />
{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 { Colors } from '@/constants/theme'
import { useColorScheme } from '@/hooks/use-color-scheme'
import { useSignIn } from '@clerk/expo'
import { type Href, useRouter } from 'expo-router'
import React from 'react'
import { Pressable, StyleSheet, TextInput } from 'react-native'
export default function Page() {
const { signIn, errors, fetchStatus } = useSignIn()
const router = useRouter()
const colorScheme = useColorScheme() ?? 'light'
const themeColors = Colors[colorScheme]
const [phoneNumber, setPhoneNumber] = React.useState('')
const [password, setPassword] = React.useState('')
const [code, setCode] = React.useState('')
const handleSubmit = async () => {
await signIn.password({
phoneNumber,
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') {
// See https://clerk.com/docs/guides/development/custom-flows/authentication/multi-factor-authentication
} else if (signIn.status === 'needs_client_trust') {
const phoneCodeFactor = signIn.supportedSecondFactors.find(
(factor): factor is PhoneCodeFactor => factor.strategy === 'phone_code',
)
if (phoneCodeFactor) {
await signIn.mfa.sendPhoneCode()
}
} else {
// Check why the sign-in is not complete
console.error('Sign-in attempt not complete:', signIn)
}
}
const handleVerify = async () => {
await signIn.mfa.verifyPhoneCode({ 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)
}
},
})
} else {
// Check why the sign-in is not complete
console.error('Sign-in attempt not complete:', signIn)
}
}
if (signIn.status === 'needs_client_trust') {
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,
{ backgroundColor: themeColors.background, borderColor: themeColors.icon },
]}
value={code}
placeholder="Enter code"
placeholderTextColor={themeColors.icon}
onChangeText={(code) => setCode(code)}
keyboardType="numeric"
/>
{errors.fields.code && (
<ThemedText style={styles.error}>{errors.fields.code.message}</ThemedText>
)}
<Pressable
style={({ pressed }) => [
styles.button,
fetchStatus === 'fetching' && styles.buttonDisabled,
pressed && styles.buttonPressed,
]}
onPress={handleVerify}
disabled={fetchStatus === 'fetching'}
>
<ThemedText style={styles.buttonText}>Verify</ThemedText>
</Pressable>
<Pressable
style={({ pressed }) => [styles.secondaryButton, pressed && styles.buttonPressed]}
onPress={() => signIn.mfa.sendPhoneCode()}
>
<ThemedText style={styles.secondaryButtonText}>I need a new code</ThemedText>
</Pressable>
<Pressable
style={({ pressed }) => [styles.secondaryButton, pressed && styles.buttonPressed]}
onPress={() => signIn.reset()}
>
<ThemedText style={styles.secondaryButtonText}>Start over</ThemedText>
</Pressable>
{errors && <ThemedText style={styles.debug}>{JSON.stringify(errors, null, 2)}</ThemedText>}
</ThemedView>
)
}
return (
<ThemedView style={styles.container}>
<ThemedText type="title" style={styles.title}>
Sign in
</ThemedText>
<ThemedText style={styles.label}>Enter phone number</ThemedText>
<TextInput
style={[
styles.input,
{ backgroundColor: themeColors.background, borderColor: themeColors.icon },
]}
value={phoneNumber}
placeholder="Enter phone number"
placeholderTextColor={themeColors.icon}
onChangeText={(phoneNumber) => setPhoneNumber(phoneNumber)}
keyboardType="phone-pad"
/>
{errors.fields.identifier && (
<ThemedText style={styles.error}>{errors.fields.identifier.message}</ThemedText>
)}
<ThemedText style={styles.label}>Enter password</ThemedText>
<TextInput
style={[
styles.input,
{ backgroundColor: themeColors.background, borderColor: themeColors.icon },
]}
value={password}
placeholder="Enter password"
placeholderTextColor={themeColors.icon}
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,
borderRadius: 8,
padding: 12,
fontSize: 16,
},
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',
},
secondaryButton: {
paddingVertical: 12,
paddingHorizontal: 24,
borderRadius: 8,
alignItems: 'center',
marginTop: 8,
},
secondaryButtonText: {
color: '#0a7ea4',
fontWeight: '600',
},
error: {
color: '#d32f2f',
fontSize: 12,
marginTop: -8,
},
debug: {
fontSize: 10,
opacity: 0.5,
marginTop: 8,
},
})Feedback
Last updated on