> ## Documentation Index
> Fetch the complete documentation index at: https://docs.prelude.so/llms.txt
> Use this file to discover all available pages before exploring further.

# OTP Login

> Implement OTP-based authentication with the Prelude mobile SDKs.

## Start an OTP login

Send a one-time code to a phone number. If your application is [configured for email OTP](/session/documentation/integration-guide/otp-login), use the email-address identifier instead to send the code via email.

The SDK sends the code and tracks the verification on the device. Pass the code the user enters to `checkOTP` to complete the login.

<Tabs>
  <Tab title="iOS">
    ```swift theme={null}
    try await client.startOTPLogin(
        StartOTPLoginOptions(
            identifier: PreludeIdentifier(type: .phoneNumber, value: "+14155551234")
        )
    )
    ```

    For email OTP, use `.emailAddress`:

    ```swift theme={null}
    try await client.startOTPLogin(
        StartOTPLoginOptions(
            identifier: PreludeIdentifier(type: .emailAddress, value: "user@example.com")
        )
    )
    ```
  </Tab>

  <Tab title="Android">
    ```kotlin theme={null}
    import so.prelude.android.auth.*

    client.startOTPLogin(
        StartOTPLoginOptions(
            identifier = PreludeIdentifier(
                type = PreludeIdentifierType.PHONE_NUMBER,
                value = "+14155551234",
            ),
        ),
    )
    ```

    For email OTP, use `EMAIL_ADDRESS`:

    ```kotlin theme={null}
    client.startOTPLogin(
        StartOTPLoginOptions(
            identifier = PreludeIdentifier(
                type = PreludeIdentifierType.EMAIL_ADDRESS,
                value = "user@example.com",
            ),
        ),
    )
    ```
  </Tab>

  <Tab title="Flutter">
    ```dart theme={null}
    import 'package:prelude_flutter_auth_sdk/prelude_flutter_auth_sdk.dart';

    await client.startOTPLogin(
      StartOTPLoginOptions(
        identifier: PreludeIdentifier.phoneNumber('+14155551234'),
      ),
    );
    ```

    For email OTP, use the `emailAddress` convenience constructor:

    ```dart theme={null}
    await client.startOTPLogin(
      StartOTPLoginOptions(
        identifier: PreludeIdentifier.emailAddress('user@example.com'),
      ),
    );
    ```
  </Tab>

  <Tab title="React Native">
    ```ts theme={null}
    import { PreludeIdentifier } from "@prelude.so/react-native-auth-sdk";

    await client.startOTPLogin({
      identifier: PreludeIdentifier.phoneNumber("+14155551234"),
    });
    ```

    For email OTP, use the `emailAddress` convenience constructor:

    ```ts theme={null}
    await client.startOTPLogin({
      identifier: PreludeIdentifier.emailAddress("user@example.com"),
    });
    ```
  </Tab>
</Tabs>

## Check the OTP code

Verify the code entered by the user. On success, `checkOTP` returns the authenticated `PreludeUser` and the SDK persists the access and refresh tokens in the platform's secure store.

<Tabs>
  <Tab title="iOS">
    ```swift theme={null}
    import PreludeAuth

    do {
        let user = try await client.checkOTP("123456")
        // User is now authenticated.
    } catch PreludeAuthError.invalidOTPCode {
        // Wrong code entered
    } catch PreludeAuthError.unauthorized {
        // Verification expired or invalid
    } catch PreludeAuthError.badRequest {
        // Server rejected the request
    } catch PreludeAuthError.rateLimited {
        // Too many attempts
    }
    ```
  </Tab>

  <Tab title="Android">
    ```kotlin theme={null}
    import so.prelude.android.auth.*

    try {
        val user = client.checkOTP("123456")
        // User is now authenticated.
    } catch (e: PreludeAuthError.InvalidOTPCode) {
        // Wrong code entered
    } catch (e: PreludeAuthError.Unauthorized) {
        // Verification expired or invalid
    } catch (e: PreludeAuthError.BadRequest) {
        // Server rejected the request
    } catch (e: PreludeAuthError.RateLimited) {
        // Too many attempts
    }
    ```
  </Tab>

  <Tab title="Flutter">
    ```dart theme={null}
    import 'package:prelude_flutter_auth_sdk/prelude_flutter_auth_sdk.dart';

    try {
      final user = await client.checkOTP('123456');
      // User is now authenticated.
    } on InvalidOTPCodeException {
      // Wrong code entered
    } on UnauthorizedException {
      // Verification expired or invalid
    } on BadRequestException {
      // Server rejected the request
    } on RateLimitedException {
      // Too many attempts
    }
    ```
  </Tab>

  <Tab title="React Native">
    ```ts theme={null}
    import {
      BadRequestError,
      InvalidOTPCodeError,
      RateLimitedError,
      UnauthorizedError,
    } from "@prelude.so/react-native-auth-sdk";

    try {
      const user = await client.checkOTP("123456");
      // User is now authenticated.
    } catch (e) {
      if (e instanceof InvalidOTPCodeError) {
        // Wrong code entered
      } else if (e instanceof UnauthorizedError) {
        // Verification expired or invalid
      } else if (e instanceof BadRequestError) {
        // Server rejected the request
      } else if (e instanceof RateLimitedError) {
        // Too many attempts
      } else {
        throw e;
      }
    }
    ```
  </Tab>
</Tabs>

## Resend the OTP

If the user didn't receive the code, ask the server to resend the most recently-issued OTP. Resend reuses the verification opened by `startOTPLogin`, so there's no need to start the flow over.

<Tabs>
  <Tab title="iOS">
    ```swift theme={null}
    try await client.resendOTP()
    ```
  </Tab>

  <Tab title="Android">
    ```kotlin theme={null}
    client.resendOTP()
    ```
  </Tab>

  <Tab title="Flutter">
    ```dart theme={null}
    await client.resendOTP();
    ```
  </Tab>

  <Tab title="React Native">
    ```ts theme={null}
    await client.resendOTP();
    ```
  </Tab>
</Tabs>

<Accordion title="Try it" icon="flask">
  <Tabs>
    <Tab title="iOS">
      Replace `ContentView.swift` with:

      ```swift ContentView.swift theme={null}
      import SwiftUI
      import PreludeAuth

      private let appID = "YOUR_APP_ID"

      enum Step { case phone, code, done }

      @MainActor
      final class OTPModel: ObservableObject {
          let client = try! PreludeAuthClient(
              endpoint: .custom("https://\(appID).session.prelude.dev")
          )

          @Published var step: Step = .phone
          @Published var user: PreludeUser?
          @Published var error: String?

          func sendCode(to phone: String) async {
              error = nil
              do {
                  try await client.startOTPLogin(
                      StartOTPLoginOptions(
                          identifier: PreludeIdentifier(type: .phoneNumber, value: phone)
                      )
                  )
                  step = .code
              } catch PreludeAuthError.badRequest {
                  error = "Invalid phone number."
              } catch {
                  self.error = "Something went wrong. Please try again."
              }
          }

          func checkCode(_ code: String) async {
              error = nil
              do {
                  user = try await client.checkOTP(code)
                  step = .done
              } catch PreludeAuthError.invalidOTPCode {
                  error = "Wrong code. Please try again."
              } catch PreludeAuthError.unauthorized {
                  error = "Verification expired. Please start over."
              } catch PreludeAuthError.rateLimited {
                  error = "Too many attempts. Please try again later."
              } catch {
                  self.error = "Something went wrong. Please try again."
              }
          }

          func resend() async {
              error = nil
              do { try await client.resendOTP() }
              catch { self.error = "Could not resend code. Please try again." }
          }
      }

      struct ContentView: View {
          @StateObject private var model = OTPModel()
          @State private var phone = ""
          @State private var code = ""

          var body: some View {
              VStack(spacing: 12) {
                  switch model.step {
                  case .phone:
                      TextField("Phone (e.g. +14155551234)", text: $phone)
                          .keyboardType(.phonePad)
                      Button("Send Code") {
                          Task { await model.sendCode(to: phone) }
                      }
                      .buttonStyle(.borderedProminent)
                  case .code:
                      TextField("Enter code", text: $code)
                          .keyboardType(.numberPad)
                      Button("Verify Code") {
                          Task { await model.checkCode(code) }
                      }
                      .buttonStyle(.borderedProminent)
                      Button("Resend Code") {
                          Task { await model.resend() }
                      }
                  case .done:
                      if let user = model.user {
                          Text("Logged in").font(.headline)
                          Text(user.profile.userID ?? "—").font(.caption.monospaced())
                      }
                  }
                  if let error = model.error {
                      Text(error).foregroundStyle(.red).font(.caption)
                  }
              }
              .textFieldStyle(.roundedBorder)
              .padding()
          }
      }
      ```
    </Tab>

    <Tab title="Android">
      Replace `MainActivity.kt` with:

      ```kotlin MainActivity.kt theme={null}
      package com.example.sessiontest

      import android.os.Bundle
      import androidx.activity.ComponentActivity
      import androidx.activity.compose.setContent
      import androidx.compose.foundation.layout.*
      import androidx.compose.material3.*
      import androidx.compose.runtime.*
      import androidx.compose.ui.Modifier
      import androidx.compose.ui.text.input.KeyboardType
      import androidx.compose.ui.text.input.KeyboardOptions
      import androidx.compose.ui.unit.dp
      import androidx.lifecycle.ViewModel
      import androidx.lifecycle.viewModelScope
      import androidx.lifecycle.viewmodel.compose.viewModel
      import kotlinx.coroutines.launch
      import so.prelude.android.auth.*
      import java.net.URL

      private const val APP_ID = "YOUR_APP_ID"

      enum class Step { Phone, Code, Done }

      class OtpViewModel(ctx: android.content.Context) : ViewModel() {
          val client = PreludeAuthClient(
              context = ctx,
              baseUrl = URL("https://$APP_ID.session.prelude.dev"),
          )

          var step by mutableStateOf(Step.Phone); private set
          var user by mutableStateOf<PreludeUser?>(null); private set
          var error by mutableStateOf<String?>(null); private set

          fun sendCode(phone: String) {
              error = null
              viewModelScope.launch {
                  try {
                      client.startOTPLogin(
                          StartOTPLoginOptions(
                              identifier = PreludeIdentifier(
                                  type = PreludeIdentifierType.PHONE_NUMBER,
                                  value = phone,
                              ),
                          ),
                      )
                      step = Step.Code
                  } catch (e: PreludeAuthError.BadRequest) {
                      error = "Invalid phone number."
                  } catch (e: PreludeAuthError) {
                      error = "Something went wrong. Please try again."
                  }
              }
          }

          fun checkCode(code: String) {
              error = null
              viewModelScope.launch {
                  try {
                      user = client.checkOTP(code)
                      step = Step.Done
                  } catch (e: PreludeAuthError.InvalidOTPCode) {
                      error = "Wrong code. Please try again."
                  } catch (e: PreludeAuthError.Unauthorized) {
                      error = "Verification expired. Please start over."
                  } catch (e: PreludeAuthError.RateLimited) {
                      error = "Too many attempts. Please try again later."
                  } catch (e: PreludeAuthError) {
                      error = "Something went wrong. Please try again."
                  }
              }
          }

          fun resend() {
              error = null
              viewModelScope.launch {
                  runCatching { client.resendOTP() }
                      .onFailure { error = "Could not resend code. Please try again." }
              }
          }
      }

      class MainActivity : ComponentActivity() {
          override fun onCreate(savedInstanceState: Bundle?) {
              super.onCreate(savedInstanceState)
              setContent {
                  MaterialTheme {
                      Surface {
                          val vm: OtpViewModel = viewModel(factory = viewModelFactory(applicationContext))
                          var phone by remember { mutableStateOf("") }
                          var code by remember { mutableStateOf("") }

                          Column(Modifier.fillMaxSize().padding(24.dp)) {
                              when (vm.step) {
                                  Step.Phone -> {
                                      OutlinedTextField(
                                          value = phone, onValueChange = { phone = it },
                                          label = { Text("Phone (e.g. +14155551234)") },
                                          keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
                                          modifier = Modifier.fillMaxWidth(),
                                      )
                                      Spacer(Modifier.height(12.dp))
                                      Button(onClick = { vm.sendCode(phone) }) { Text("Send Code") }
                                  }
                                  Step.Code -> {
                                      OutlinedTextField(
                                          value = code, onValueChange = { code = it },
                                          label = { Text("Enter code") },
                                          keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
                                          modifier = Modifier.fillMaxWidth(),
                                      )
                                      Spacer(Modifier.height(12.dp))
                                      Row {
                                          Button(onClick = { vm.checkCode(code) }) { Text("Verify Code") }
                                          Spacer(Modifier.width(8.dp))
                                          OutlinedButton(onClick = { vm.resend() }) { Text("Resend Code") }
                                      }
                                  }
                                  Step.Done -> {
                                      Text("Logged in", style = MaterialTheme.typography.headlineSmall)
                                      Text(vm.user?.profile?.userId ?: "—")
                                  }
                              }
                              vm.error?.let {
                                  Spacer(Modifier.height(12.dp))
                                  Text(it, color = MaterialTheme.colorScheme.error)
                              }
                          }
                      }
                  }
              }
          }
      }
      ```

      (`viewModelFactory` is the small helper defined in [Introduction](/session/documentation/frontend-sdks/mobile/introduction#helpers).)
    </Tab>

    <Tab title="Flutter">
      Replace `lib/main.dart` with:

      ```dart lib/main.dart theme={null}
      import 'package:flutter/material.dart';
      import 'package:prelude_flutter_auth_sdk/prelude_flutter_auth_sdk.dart';

      const appID = 'YOUR_APP_ID';

      void main() => runApp(const MyApp());

      enum _Step { phone, code, done }

      class MyApp extends StatefulWidget {
        const MyApp({super.key});
        @override
        State<MyApp> createState() => _MyAppState();
      }

      class _MyAppState extends State<MyApp> {
        late final client = PreludeAuthClient(
          endpoint: Endpoint.custom('https://$appID.session.prelude.dev'),
        );

        final _phone = TextEditingController();
        final _code = TextEditingController();
        _Step _step = _Step.phone;
        PreludeUser? _user;
        String? _error;

        Future<void> _send() async {
          setState(() => _error = null);
          try {
            await client.startOTPLogin(
              StartOTPLoginOptions(
                identifier: PreludeIdentifier.phoneNumber(_phone.text),
              ),
            );
            setState(() => _step = _Step.code);
          } on BadRequestException {
            setState(() => _error = 'Invalid phone number.');
          } on PreludeAuthException {
            setState(() => _error = 'Something went wrong. Please try again.');
          }
        }

        Future<void> _check() async {
          setState(() => _error = null);
          try {
            final user = await client.checkOTP(_code.text);
            setState(() {
              _user = user;
              _step = _Step.done;
            });
          } on InvalidOTPCodeException {
            setState(() => _error = 'Wrong code. Please try again.');
          } on UnauthorizedException {
            setState(() => _error = 'Verification expired. Please start over.');
          } on RateLimitedException {
            setState(() => _error = 'Too many attempts. Please try again later.');
          } on PreludeAuthException {
            setState(() => _error = 'Something went wrong. Please try again.');
          }
        }

        Future<void> _resend() async {
          setState(() => _error = null);
          try {
            await client.resendOTP();
          } on PreludeAuthException {
            setState(() => _error = 'Could not resend code. Please try again.');
          }
        }

        @override
        void dispose() {
          _phone.dispose();
          _code.dispose();
          client.dispose();
          super.dispose();
        }

        @override
        Widget build(BuildContext context) {
          Widget body;
          switch (_step) {
            case _Step.phone:
              body = Column(mainAxisSize: MainAxisSize.min, children: [
                TextField(
                  controller: _phone,
                  keyboardType: TextInputType.phone,
                  decoration: const InputDecoration(
                    labelText: 'Phone (e.g. +14155551234)',
                  ),
                ),
                const SizedBox(height: 12),
                FilledButton(onPressed: _send, child: const Text('Send Code')),
              ]);
            case _Step.code:
              body = Column(mainAxisSize: MainAxisSize.min, children: [
                TextField(
                  controller: _code,
                  keyboardType: TextInputType.number,
                  decoration: const InputDecoration(labelText: 'Enter code'),
                ),
                const SizedBox(height: 12),
                Row(mainAxisAlignment: MainAxisAlignment.center, children: [
                  FilledButton(onPressed: _check, child: const Text('Verify Code')),
                  const SizedBox(width: 8),
                  OutlinedButton(onPressed: _resend, child: const Text('Resend Code')),
                ]),
              ]);
            case _Step.done:
              body = Column(mainAxisSize: MainAxisSize.min, children: [
                const Text('Logged in', style: TextStyle(fontSize: 20)),
                const SizedBox(height: 8),
                Text(_user?.profile.userID ?? '—',
                    style: const TextStyle(fontFamily: 'monospace')),
              ]);
          }

          return MaterialApp(
            home: Scaffold(
              body: Padding(
                padding: const EdgeInsets.all(24),
                child: Center(
                  child: Column(mainAxisSize: MainAxisSize.min, children: [
                    body,
                    if (_error != null) ...[
                      const SizedBox(height: 12),
                      Text(_error!, style: const TextStyle(color: Colors.red)),
                    ],
                  ]),
                ),
              ),
            ),
          );
        }
      }
      ```
    </Tab>

    <Tab title="React Native">
      Replace `app/index.tsx` with:

      ```tsx app/index.tsx theme={null}
      import {
        Endpoint,
        InvalidOTPCodeError,
        PreludeIdentifier,
        PreludeAuthClient,
        PreludeAuthError,
        PreludeUser,
        RateLimitedError,
        UnauthorizedError,
      } from "@prelude.so/react-native-auth-sdk";
      import { useEffect, useRef, useState } from "react";
      import { Button, Text, TextInput, View } from "react-native";

      const APP_ID = "YOUR_APP_ID";

      type Step = "phone" | "code" | "done";

      export default function Home() {
        const clientRef = useRef<PreludeAuthClient | null>(null);
        const [step, setStep] = useState<Step>("phone");
        const [phone, setPhone] = useState("");
        const [code, setCode] = useState("");
        const [user, setUser] = useState<PreludeUser | null>(null);
        const [error, setError] = useState<string | null>(null);

        useEffect(() => {
          const c = new PreludeAuthClient({
            endpoint: Endpoint.custom(`https://${APP_ID}.session.prelude.dev`),
          });
          clientRef.current = c;
          return () => { c.dispose().catch(() => {}); };
        }, []);

        async function sendCode() {
          setError(null);
          try {
            await clientRef.current!.startOTPLogin({
              identifier: PreludeIdentifier.phoneNumber(phone),
            });
            setStep("code");
          } catch (e) {
            setError("Something went wrong. Please try again.");
          }
        }

        async function checkCode() {
          setError(null);
          try {
            const u = await clientRef.current!.checkOTP(code);
            setUser(u);
            setStep("done");
          } catch (e) {
            if (e instanceof InvalidOTPCodeError) setError("Wrong code. Please try again.");
            else if (e instanceof UnauthorizedError) setError("Verification expired. Please start over.");
            else if (e instanceof RateLimitedError) setError("Too many attempts. Please try again later.");
            else if (e instanceof PreludeAuthError) setError("Something went wrong.");
            else throw e;
          }
        }

        async function resend() {
          setError(null);
          try {
            await clientRef.current!.resendOTP();
          } catch {
            setError("Could not resend code. Please try again.");
          }
        }

        return (
          <View style={{ padding: 24, gap: 8 }}>
            {step === "phone" && (
              <>
                <TextInput
                  placeholder="Phone (e.g. +14155551234)"
                  value={phone}
                  onChangeText={setPhone}
                  keyboardType="phone-pad"
                  style={{ borderWidth: 1, padding: 8 }}
                />
                <Button title="Send Code" onPress={sendCode} />
              </>
            )}
            {step === "code" && (
              <>
                <TextInput
                  placeholder="Enter code"
                  value={code}
                  onChangeText={setCode}
                  keyboardType="number-pad"
                  style={{ borderWidth: 1, padding: 8 }}
                />
                <Button title="Verify Code" onPress={checkCode} />
                <Button title="Resend Code" onPress={resend} />
              </>
            )}
            {step === "done" && user && (
              <>
                <Text style={{ fontWeight: "600" }}>Logged in</Text>
                <Text>{user.profile.userID ?? "—"}</Text>
              </>
            )}
            {error && <Text style={{ color: "red" }}>{error}</Text>}
          </View>
        );
      }
      ```
    </Tab>
  </Tabs>
</Accordion>
