Skip to main content
Docs

Build a custom email or SMS OTP authentication flow

Warning

This guide is for users who want to build a custom user interface using the Clerk API. To use a prebuilt UI, use the Account Portal pages or prebuilt components.

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 (OTP), also known as a one-time code, via email or SMS, which they can use to authenticate themselves.

This guide will walk you through how to build a custom SMS OTP sign-up and sign-in flow. The process for using email OTP is similar, and the differences will be highlighted throughout.

Warning

Phone numbers must be in E.164 format.

Enable SMS OTP

To use SMS OTP, you first need to enable it for your application.

  1. In the Clerk Dashboard, navigate to the User & authentication page.
  2. Select the Phone tab and enable Sign-up with phone and Sign-in with phone and keep the default settings.

Sign-up flow

To sign up a user using an OTP, you must:

  1. Initiate the sign-up process by collecting the user's identifier, which for this example is a phone number.
  2. Prepare the verification, which sends a one-time code to the given identifier.
  3. Attempt to complete the verification with the code the user provides.
  4. If the verification is successful, set the newly created session as the active session.
SMSOTPSignUpView.swift
import SwiftUI
import Clerk

struct SMSOTPSignUpView: View {
@State private var phoneNumber = ""
@State private var code = ""
@State private var isVerifying = false

var body: some View {
    if isVerifying {
    TextField("Enter your verification code", text: $code)
    Button("Verify") {
        Task { await verify(code: code) }
    }
    } else {
    TextField("Enter phone number", text: $phoneNumber)
    Button("Continue") {
        Task { await submit(phoneNumber: phoneNumber) }
    }
    }
}
}

extension SMSOTPSignUpView {

func submit(phoneNumber: String) async {
    do {
    // Start the sign-up process using the phone number method.
    let signUp = try await SignUp.create(strategy: .standard(phoneNumber: phoneNumber))

    // Start the verification - a SMS message will be sent to the
    // number with a one-time code.
    try await signUp.prepareVerification(strategy: .phoneCode)

    // Set isVerifying to true to display second form and capture the OTP code.
    isVerifying = true
    } 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 {
    // Access the in progress sign up stored on the client object.
    guard let inProgressSignUp = Clerk.shared.client?.signUp else { return }

    // Use the code provided by the user and attempt verification.
    let signUp = try await inProgressSignUp.attemptVerification(strategy: .phoneCode(code: code))

    switch signUp.status {
    case .complete:
        // If verification was completed, navigate the user as needed.
        dump(Clerk.shared.session)
    default:
        // If the status is not complete, check why. User may need to
        // complete further steps.
        dump(signUp.status)
    }
    } catch {
    // See https://clerk.com/docs/guides/development/custom-flows/error-handling
    // for more info on error handling
    dump(error)
    }
}
}
SMSOTPSignUpViewModel.kt
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.clerk.api.Clerk
import com.clerk.api.network.serialization.flatMap
import com.clerk.api.network.serialization.onFailure
import com.clerk.api.network.serialization.onSuccess
import com.clerk.api.signup.SignUp
import com.clerk.api.signup.attemptVerification
import com.clerk.api.signup.prepareVerification
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.launch

class SMSOTPSignUpViewModel : ViewModel() {

private val _uiState = MutableStateFlow<UiState>(UiState.Unverified)
val uiState = _uiState.asStateFlow()

init {
combine(Clerk.isInitialized, Clerk.userFlow) { isInitialized, user ->
    _uiState.value =
    when {
        !isInitialized -> UiState.Loading
        user == null -> UiState.Unverified
        else -> UiState.Verified
    }
}
.launchIn(viewModelScope)
}

fun submit(phoneNumber: String) {
viewModelScope.launch {
SignUp.create(SignUp.CreateParams.Standard(phoneNumber = phoneNumber))
    .flatMap { it.prepareVerification(SignUp.PrepareVerificationParams.Strategy.PhoneCode()) }
    .onSuccess { _uiState.value = UiState.Verifying }
    .onFailure {
    // See https://clerk.com/docs/guides/development/custom-flows/error-handling
    // for more info on error handling
    }
}
}

fun verify(code: String) {
val inProgressSignUp = Clerk.signUp ?: return
viewModelScope.launch {
inProgressSignUp
    .attemptVerification(SignUp.AttemptVerificationParams.PhoneCode(code))
    .onSuccess {
    if (it.status == SignUp.Status.COMPLETE) {
        _uiState.value = UiState.Verified
    } else {
        // The user may need to complete further steps
    }
    }
    .onFailure {
    // See https://clerk.com/docs/guides/development/custom-flows/error-handling
    // for more info on error handling
    }
}
}

sealed interface UiState {
data object Loading : UiState

data object Unverified : UiState

data object Verifying : UiState

data object Verified : UiState
}
}
SMSOTPSignUpActivity.kt
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.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
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.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle

class SMSOTPSignUpActivity : ComponentActivity() {
  val viewModel: SMSOTPSignUpViewModel by viewModels()

  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContent {
      val state by viewModel.uiState.collectAsStateWithLifecycle()
      SMSOTPSignUpView(state, viewModel::submit, viewModel::verify)
    }
  }
}

@Composable
fun SMSOTPSignUpView(
  state: SMSOTPSignUpViewModel.UiState,
  onSubmit: (String) -> Unit,
  onVerify: (String) -> Unit,
) {
  Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
  when (state) {
      SMSOTPSignUpViewModel.UiState.Unverified -> {
        InputContent(
          placeholder = "Enter your phone number",
          buttonText = "Continue",
          onClick = onSubmit,
        )
      }
      SMSOTPSignUpViewModel.UiState.Verified -> {
        Text("Verified")
      }
      SMSOTPSignUpViewModel.UiState.Verifying -> {
        InputContent(
          placeholder = "Enter your verification code",
          buttonText = "Verify",
          onClick = onVerify,
        )
      }

      SMSOTPSignUpViewModel.UiState.Loading -> {
      CircularProgressIndicator()
      }
    }
  }
}

@Composable
fun InputContent(placeholder: String, buttonText: String, onClick: (String) -> Unit) {
  var value by remember { mutableStateOf("") }
  Column(
  horizontalAlignment = Alignment.CenterHorizontally,
  verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically),
  ) {
    TextField(placeholder = { Text(placeholder) }, value = value, onValueChange = { value = it })
    Button(onClick = { onClick(value) }) { Text(buttonText) }
  }
}

To create a sign-up flow for email OTP, use the and . These helpers work the same way as their phone number counterparts do in the previous example. You can find all available methods in the object documentation.

Sign-in flow

To authenticate a user with an OTP, you must:

  1. Initiate the sign-in process by creating a SignIn using the identifier provided, which for this example is a phone number.
  2. Prepare the first factor verification.
  3. Attempt verification with the code the user provides.
  4. If the attempt is successful, set the newly created session as the active session.
SMSOTPSignInView.swift
import SwiftUI
import Clerk

struct SMSOTPSignInView: View {
@State private var phoneNumber = ""
@State private var code = ""
@State private var isVerifying = false

var body: some View {
    if isVerifying {
    TextField("Enter your verification code", text: $code)
    Button("Verify") {
        Task { await verify(code: code) }
    }
    } else {
    TextField("Enter phone number", text: $phoneNumber)
    Button("Continue") {
        Task { await submit(phoneNumber: phoneNumber) }
    }
    }
}
}

extension SMSOTPSignInView {

func submit(phoneNumber: String) async {
    do {
    // Start the sign-in process using the phone number method.
    let signIn = try await SignIn.create(strategy: .identifier(phoneNumber))

    // Send the OTP code to the user.
    try await signIn.prepareFirstFactor(strategy: .phoneCode())

    // Set isVerifying to true to display second form
    // and capture the OTP code.
    isVerifying = true
    } 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 {
    // Access the in progress sign in stored on the client object.
    guard let inProgressSignIn = Clerk.shared.client?.signIn else { return }

    // Use the code provided by the user and attempt verification.
    let signIn = try await inProgressSignIn.attemptFirstFactor(strategy: .phoneCode(code: code))

    switch signIn.status {
    case .complete:
        // If verification was completed, navigate the user as needed.
        dump(Clerk.shared.session)
    default:
        // If the status is not complete, 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)
    }
}
}
SMSOTPSignInViewModel.kt
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.clerk.api.Clerk
import com.clerk.api.network.serialization.flatMap
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.attemptFirstFactor
import com.clerk.api.signin.prepareFirstFactor
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.launch

class SMSOTPSignInViewModel : ViewModel() {
private val _uiState = MutableStateFlow<UiState>(UiState.Unverified)
val uiState = _uiState.asStateFlow()

init {
    combine(Clerk.isInitialized, Clerk.userFlow) { isInitialized, user ->
        _uiState.value =
        when {
            !isInitialized -> UiState.Loading
            user == null -> UiState.Unverified
            else -> UiState.Verified
        }
    }
    .launchIn(viewModelScope)
}

fun submit(phoneNumber: String) {
    viewModelScope.launch {
    SignIn.create(SignIn.CreateParams.Strategy.PhoneCode(phoneNumber)).flatMap {
        it
        .prepareFirstFactor(SignIn.PrepareFirstFactorParams.PhoneCode())
        .onSuccess { _uiState.value = UiState.Verifying }
        .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
        .attemptFirstFactor(SignIn.AttemptFirstFactorParams.PhoneCode(code))
        .onSuccess {
        if (it.status == SignIn.Status.COMPLETE) {
            _uiState.value = UiState.Verified
        } else {
            // The user may need to complete further steps
        }
        }
        .onFailure {
        // See https://clerk.com/docs/guides/development/custom-flows/error-handling
        // for more info on error handling
        }
    }
}

sealed interface UiState {
    data object Loading : UiState

    data object Unverified : UiState

    data object Verifying : UiState

    data object Verified : UiState
}
}
SMSOTPSignInActivity.kt
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.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
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.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle

class SMSOTPSignInActivity : ComponentActivity() {
  val viewModel: SMSOTPSignInViewModel by viewModels()

  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContent {
      val state by viewModel.uiState.collectAsStateWithLifecycle()
      SMSOTPSignInView(state, viewModel::submit, viewModel::verify)
    }
  }
}

@Composable
fun SMSOTPSignInView(
  state: SMSOTPSignInViewModel.UiState,
  onSubmit: (String) -> Unit,
  onVerify: (String) -> Unit,
) {
  Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
    when (state) {
      SMSOTPSignInViewModel.UiState.Unverified -> {
        InputContent(
          placeholder = "Enter your phone number",
          buttonText = "Continue",
          onClick = onSubmit,
        )
      }
      SMSOTPSignInViewModel.UiState.Verified -> {
        Text("Verified")
      }
      SMSOTPSignInViewModel.UiState.Verifying -> {
        InputContent(
          placeholder = "Enter your verification code",
          buttonText = "Verify",
          onClick = onVerify,
        )
      }

      SMSOTPSignInViewModel.UiState.Loading -> {
        CircularProgressIndicator()
      }
    }
  }
}

@Composable
fun InputContent(placeholder: String, buttonText: String, onClick: (String) -> Unit) {
  var value by remember { mutableStateOf("") }
  Column(
    horizontalAlignment = Alignment.CenterHorizontally,
    verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically),
  ) {
    TextField(placeholder = { Text(placeholder) }, value = value, onValueChange = { value = it })
    Button(onClick = { onClick(value) }) { Text(buttonText) }
  }
}

To create a sign-in flow for email OTP, pass the value email_code as the first factor strategy. You can find all available methods in the object documentation.

Feedback

What did you think of this content?

Last updated on