Build a custom email or phone OTP authentication flow
Clerk supports passwordless authentication, which lets users sign in and sign up without having to remember a password. Instead, users receive a one-time password () via email or phone, which they can use to authenticate themselves.
This guide will walk you through how to build a custom phone OTP sign-up and sign-in flow. The process for using email OTP is similar, and the differences will be highlighted throughout.
Enable phone OTP
To use phone , you first need to enable it for your application.
- In the Clerk Dashboard, navigate to the User & authentication page.
- Select the Phone tab and enable Sign-up with phone and Sign-in with phone. It's recommended to enable Verify at sign-up.
Sign-up flow
To sign up a user using an , you must:
- Initiate the sign-up process by collecting the user's identifier, which for this example is a phone number.
- Send the user an to the given identifier.
- Verify the code supplied by the user.
To create a sign-up flow for email , the flow is the same except you'll swap phoneNumber for emailAddress throughout the code. You can find all available methods in the SignUp object documentation.
'use client'
import * as React from 'react'
import { useSignUp } from '@clerk/nextjs/legacy'
import { useRouter } from 'next/navigation'
export default function Page() {
const { isLoaded, signUp, setActive } = useSignUp()
const [verifying, setVerifying] = React.useState(false)
const [phoneNumber, setPhoneNumber] = React.useState('')
const [code, setCode] = React.useState('')
const router = useRouter()
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!isLoaded && !signUp) return null
try {
// Start sign-up process using the phone number provided
await signUp.create({
phoneNumber,
})
// Start the verification - a text message will be sent to the
// number with a one-time password (OTP)
await signUp.preparePhoneNumberVerification()
// Set verifying to true to display second form
// and capture the OTP code
setVerifying(true)
} 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))
}
}
async function handleVerification(e: React.FormEvent) {
e.preventDefault()
if (!isLoaded && !signUp) return null
try {
// Use the code provided by the user and attempt verification
const signUpAttempt = await signUp.attemptPhoneNumberVerification({
code,
})
// If verification was completed, set the session to active
// and redirect the user
if (signUpAttempt.status === 'complete') {
await setActive({
session: signUpAttempt.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(signUpAttempt)
}
} 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 (verifying) {
return (
<>
<h1>Verify your phone number</h1>
<form onSubmit={handleVerification}>
<label htmlFor="code">Enter your verification code</label>
<input value={code} id="code" name="code" onChange={(e) => setCode(e.target.value)} />
<button type="submit">Verify</button>
</form>
</>
)
}
return (
<>
<h1>Sign up</h1>
<form onSubmit={handleSubmit}>
<label htmlFor="phone">Enter phone number</label>
<input
value={phoneNumber}
id="phone"
name="phone"
type="tel"
onChange={(e) => setPhoneNumber(e.target.value)}
/>
<button type="submit">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-up">
<h2>Sign up</h2>
<form id="sign-up-form">
<label for="phone">Enter phone number</label>
<input type="tel" name="phone" id="sign-up-phone" />
<button type="submit">Continue</button>
</form>
</div>
<form id="verifying" hidden>
<h2>Verify your phone number</h2>
<label for="code">Enter your verification code</label>
<input id="code" name="code" />
<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-up form
document.getElementById('sign-up-form').addEventListener('submit', async (e) => {
e.preventDefault()
const formData = new FormData(e.target)
const phoneNumber = formData.get('phone')
try {
// Start the sign-up process using the phone number method
await clerk.client.signUp.create({ phoneNumber })
await clerk.client.signUp.preparePhoneNumberVerification()
// Hide sign-up form
document.getElementById('sign-up').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 code = formData.get('code')
try {
// Verify the phone number
const verify = await clerk.client.signUp.attemptPhoneNumberVerification({
code,
})
// Now that the user is created, set the session to active.
await clerk.setActive({ session: verify.createdSessionId })
} catch (error) {
// See https://clerk.com/docs/guides/development/custom-flows/error-handling
// for more info on error handling
console.error(error)
}
})
}import { ThemedText } from '@/components/themed-text'
import { ThemedView } from '@/components/themed-view'
import { useSignUp } from '@clerk/expo/legacy'
import { Link, useRouter } from 'expo-router'
import * as React from 'react'
import { Pressable, StyleSheet, TextInput, View } from 'react-native'
export default function Page() {
const { isLoaded, signUp, setActive } = useSignUp()
const router = useRouter()
const [phoneNumber, setPhoneNumber] = React.useState('')
const [code, setCode] = React.useState('')
const [pendingVerification, setPendingVerification] = React.useState(false)
// Handle submission of sign-up form
const onSignUpPress = async () => {
if (!isLoaded) return
// Start sign-up process using the phone number provided
try {
await signUp.create({
phoneNumber,
})
// Start the verification - a text message will be sent to the
// number with a one-time password (OTP)
await signUp.preparePhoneNumberVerification()
// Set `verifying` to `true` to display second form
// and capture the OTP code
setPendingVerification(true)
} 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 submission of verification form
const onVerifyPress = async () => {
if (!isLoaded) return
try {
// Use the code the user provided to attempt verification
const signUpAttempt = await signUp.attemptPhoneNumberVerification({
code,
})
// If verification was completed, set the session to active
// and redirect the user
if (signUpAttempt.status === 'complete') {
await setActive({
session: signUpAttempt.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(signUpAttempt, 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))
}
}
if (pendingVerification) {
return (
<ThemedView style={styles.container}>
<ThemedText type="title" style={styles.title}>
Verify your phone number
</ThemedText>
<ThemedText style={styles.description}>
A verification code has been sent to your phone.
</ThemedText>
<TextInput
style={styles.input}
value={code}
placeholder="Enter verification code"
placeholderTextColor="#666666"
onChangeText={(code) => setCode(code)}
keyboardType="numeric"
/>
<Pressable
style={({ pressed }) => [styles.button, pressed && styles.buttonPressed]}
onPress={onVerifyPress}
>
<ThemedText style={styles.buttonText}>Verify</ThemedText>
</Pressable>
</ThemedView>
)
}
return (
<ThemedView style={styles.container}>
<ThemedText type="title" style={styles.title}>
Sign up
</ThemedText>
<ThemedText style={styles.label}>Phone number</ThemedText>
<TextInput
style={styles.input}
keyboardType="phone-pad"
value={phoneNumber}
placeholder="Enter phone number"
placeholderTextColor="#666666"
onChangeText={(phoneNumber) => setPhoneNumber(phoneNumber)}
/>
<Pressable
style={({ pressed }) => [
styles.button,
!phoneNumber && styles.buttonDisabled,
pressed && styles.buttonPressed,
]}
onPress={onSignUpPress}
disabled={!phoneNumber}
>
<ThemedText style={styles.buttonText}>Continue</ThemedText>
</Pressable>
<View style={styles.linkContainer}>
<ThemedText>Have an account? </ThemedText>
<Link href="/sign-in">
<ThemedText type="link">Sign in</ThemedText>
</Link>
</View>
</ThemedView>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
padding: 20,
gap: 12,
},
title: {
marginBottom: 8,
},
description: {
fontSize: 14,
marginBottom: 16,
opacity: 0.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',
},
linkContainer: {
flexDirection: 'row',
gap: 4,
marginTop: 12,
alignItems: 'center',
},
})Sign-in flow
To authenticate a user with an , you must:
- Initiate the sign-in process by creating a
SignInusing the identifier provided, which for this example is a phone number. - Send the user an to the given identifier.
- Verify the code supplied by the user.
To create a sign-in flow for email OTP, it's the same except you'll swap phone for email and phoneNumber for emailAddress throughout the code. You can find all available methods in the SignIn object documentation.
'use client'
import * as React from 'react'
import { useSignIn } from '@clerk/nextjs/legacy'
import { PhoneCodeFactor, SignInFirstFactor } from '@clerk/shared/types'
import { useRouter } from 'next/navigation'
export default function Page() {
const { isLoaded, signIn, setActive } = useSignIn()
const router = useRouter()
const [phoneNumber, setPhoneNumber] = React.useState('')
const [code, setCode] = React.useState('')
const [verifying, setVerifying] = React.useState(false)
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!isLoaded && !signIn) return null
try {
// Start the sign-in process using the phone number method
const { supportedFirstFactors } = await signIn.create({
identifier: phoneNumber,
})
// Check if `phone_code` is a valid first factor
// This is required when Client Trust is enabled and the user
// is signing in from a new device.
// See https://clerk.com/docs/guides/secure/client-trust
const isPhoneCodeFactor = (factor: SignInFirstFactor): factor is PhoneCodeFactor => {
return factor.strategy === 'phone_code'
}
const phoneCodeFactor = supportedFirstFactors?.find(isPhoneCodeFactor)
if (phoneCodeFactor) {
// Grab the phoneNumberId
const { phoneNumberId } = phoneCodeFactor
// Send the OTP code to the user
await signIn.prepareFirstFactor({
strategy: 'phone_code',
phoneNumberId,
})
// Set verifying to true to display second form
// and capture the OTP code
setVerifying(true)
}
} 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))
}
}
async function handleVerification(e: React.FormEvent) {
e.preventDefault()
if (!isLoaded && !signIn) return null
try {
// Use the code provided by the user and attempt verification
const signInAttempt = await signIn.attemptFirstFactor({
strategy: 'phone_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.error(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 (verifying) {
return (
<>
<h1>Verify your phone number</h1>
<form onSubmit={handleVerification}>
<label htmlFor="code">Enter your verification code</label>
<input value={code} id="code" name="code" onChange={(e) => setCode(e.target.value)} />
<button type="submit">Verify</button>
</form>
</>
)
}
return (
<>
<h1>Sign in</h1>
<form onSubmit={handleSubmit}>
<label htmlFor="phone">Enter phone number</label>
<input
value={phoneNumber}
id="phone"
name="phone"
type="tel"
onChange={(e) => setPhoneNumber(e.target.value)}
/>
<button type="submit">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="sign-in">
<h2>Sign in</h2>
<form id="sign-in-form">
<label for="phone">Enter phone number</label>
<input type="tel" name="phone" id="sign-in-phone" />
<button type="submit">Continue</button>
</form>
</div>
<form id="verifying" hidden>
<h2>Verify your phone number</h2>
<label for="code">Enter your verification code</label>
<input id="code" name="code" />
<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 {
// Handle the sign-in form
document.getElementById('sign-in-form').addEventListener('submit', async (e) => {
e.preventDefault()
const formData = new FormData(e.target)
const phone = formData.get('phone')
try {
// Start the sign-in process using the user's identifier.
// In this case, it's their phone number.
const { supportedFirstFactors } = await clerk.client.signIn.create({
identifier: phone,
})
// Find the phoneNumberId from all the available first factors for the current sign-in
const firstPhoneFactor = supportedFirstFactors.find((factor) => {
return factor.strategy === 'phone_code'
})
const { phoneNumberId } = firstPhoneFactor
// Prepare first factor verification, specifying
// the phone code strategy.
await clerk.client.signIn.prepareFirstFactor({
strategy: 'phone_code',
phoneNumberId,
})
// 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 code = formData.get('code')
try {
// Verify the phone number
const verify = await clerk.client.signIn.attemptFirstFactor({
strategy: 'phone_code',
code,
})
// Now that the user is created, set the session to active.
await clerk.setActive({ session: verify.createdSessionId })
} catch (error) {
// See https://clerk.com/docs/guides/development/custom-flows/error-handling
// for more info on error handling
console.error(error)
}
})
}import { ThemedText } from '@/components/themed-text'
import { ThemedView } from '@/components/themed-view'
import { useSignIn } from '@clerk/expo/legacy'
import type { PhoneCodeFactor, SignInFirstFactor } from '@clerk/shared/types'
import { Link, useRouter } from 'expo-router'
import React from 'react'
import { Pressable, StyleSheet, TextInput, View } from 'react-native'
export default function Page() {
const { signIn, setActive, isLoaded } = useSignIn()
const router = useRouter()
const [phoneNumber, setPhoneNumber] = React.useState('')
const [code, setCode] = React.useState('')
const [verifying, setVerifying] = React.useState(false)
// Handle the submission of the sign-in form
const onSignInPress = async () => {
if (!isLoaded) return
// Start the sign-in process using the phone number provided
try {
const { supportedFirstFactors } = await signIn.create({
identifier: phoneNumber,
})
// Check if `phone_code` is a valid first factor
// This is required when Client Trust is enabled and the user
// is signing in from a new device.
// See https://clerk.com/docs/guides/secure/client-trust
const isPhoneCodeFactor = (factor: SignInFirstFactor): factor is PhoneCodeFactor => {
return factor.strategy === 'phone_code'
}
const phoneCodeFactor = supportedFirstFactors?.find(isPhoneCodeFactor)
if (phoneCodeFactor) {
// Grab the phoneNumberId
const { phoneNumberId } = phoneCodeFactor
// Send the OTP code to the user
await signIn.prepareFirstFactor({
strategy: 'phone_code',
phoneNumberId,
})
// Set verifying to true to display second form
// and capture the OTP code
setVerifying(true)
}
} 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 phone verification code
const onVerifyPress = async () => {
if (!isLoaded) return
try {
// Use the code the user provided to attempt verification
const signInAttempt = await signIn.attemptFirstFactor({
strategy: 'phone_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.error(JSON.stringify(signInAttempt, 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))
}
}
// Display phone code verification form
if (verifying) {
return (
<ThemedView style={styles.container}>
<ThemedText type="title" style={styles.title}>
Verify your phone number
</ThemedText>
<ThemedText style={styles.description}>
A verification code has been sent to your phone.
</ThemedText>
<TextInput
style={styles.input}
keyboardType="numeric"
value={code}
placeholder="Enter verification code"
placeholderTextColor="#666666"
onChangeText={(code) => setCode(code)}
/>
<Pressable
style={({ pressed }) => [styles.button, pressed && styles.buttonPressed]}
onPress={onVerifyPress}
>
<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}>Phone number</ThemedText>
<TextInput
style={styles.input}
keyboardType="phone-pad"
value={phoneNumber}
placeholder="Enter phone number"
placeholderTextColor="#666666"
onChangeText={(phoneNumber) => setPhoneNumber(phoneNumber)}
/>
<Pressable
style={({ pressed }) => [
styles.button,
!phoneNumber && styles.buttonDisabled,
pressed && styles.buttonPressed,
]}
onPress={onSignInPress}
disabled={!phoneNumber}
>
<ThemedText style={styles.buttonText}>Sign in</ThemedText>
</Pressable>
<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,
},
description: {
fontSize: 14,
marginBottom: 16,
opacity: 0.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',
},
linkContainer: {
flexDirection: 'row',
gap: 4,
marginTop: 12,
alignItems: 'center',
},
})Feedback
Last updated on