> ## 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.

# Step-Up Authentication

> Implement step-up authentication with the Prelude mobile SDKs.

## Request a scope

Start a step-up flow for a given scope. The returned challenge tells you what the server wants next — typically an OTP delivery step (`verify_email` or `verify_sms`). Code delivery is caller-driven: you pass the challenge to `sendStepUpOTP` when your UI is ready (see the next section).

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

    do {
        let challenge = try await client.requestStepUp(scope: "transfer:write")

        switch challenge.status {
        case .blocked:
            // Your hook denied this scope.
        case .continue, .underReview:
            // Drive `challenge.currentStep` (typically "verify_sms" or
            // "verify_email") and call submitStepUpOTP below.
            break
        }
    } catch PreludeAuthError.forbidden {
        // Scope is not in the allowed_scopes configuration.
    }
    ```

    `StepUpChallenge` is a value type — each caller holds its own copy, so concurrent step-up flows on a single client don't share state. The most recent challenge is also available for diagnostics:

    ```swift theme={null}
    let active = await client.activeStepUp
    ```
  </Tab>

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

    try {
        val challenge = client.requestStepUp("transfer:write")

        when (challenge.status) {
            PreludeStepUpStatus.BLOCKED -> {
                // Your hook denied this scope.
            }
            PreludeStepUpStatus.CONTINUE -> {
                // Drive `challenge.currentStep` (typically "verify_sms" or
                // "verify_email") and call submitStepUpOTP below.
            }
            PreludeStepUpStatus.UNDER_REVIEW -> {
                // The server is still reviewing — there's nothing to
                // submit yet. Poll or show a waiting UI until your
                // hook updates the status.
            }
        }
    } catch (e: PreludeAuthError.Forbidden) {
        // Scope is not in the allowed_scopes configuration.
    }
    ```

    `PreludeStepUpChallenge` is an immutable value type — each caller holds its own copy, so concurrent step-up flows on a single client don't share state. The most recent challenge is also available for diagnostics:

    ```kotlin theme={null}
    val active = client.activeStepUp
    ```
  </Tab>

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

    try {
      final challenge = await client.requestStepUp(scope: 'transfer:write');

      switch (challenge.status) {
        case StepUpStatus.blocked:
          // Your hook denied this scope.
          break;
        case StepUpStatus.continueStep:
        case StepUpStatus.underReview:
          // Drive `challenge.currentStep` (typically "verify_sms" or
          // "verify_email") and call submitStepUpOTP below.
          break;
      }
    } on ForbiddenException {
      // Scope is not in the allowed_scopes configuration.
    }
    ```

    Sensitive parts of the challenge — the challenge token and its expiry — stay on the native side and are looked up by `challengeID`. The Dart layer never holds the bearer token, so it can't end up in your logs or debugger.
  </Tab>

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

    try {
      const challenge = await client.requestStepUp("transfer:write");

      switch (challenge.status) {
        case "block":
          // Your hook denied this scope.
          break;
        case "continue":
        case "review":
          // Drive `challenge.currentStep` (typically "verify_sms" or
          // "verify_email") and call submitStepUpOTP below.
          break;
      }
    } catch (e) {
      if (e instanceof ForbiddenError) {
        // Scope is not in the allowed_scopes configuration.
      } else {
        throw e;
      }
    }
    ```

    Sensitive parts of the challenge — the challenge token and its expiry — stay on the native side and are looked up by `challengeID`. The JS layer never holds the bearer token, so it can't end up in your logs or debugger.
  </Tab>
</Tabs>

The challenge exposes the following fields:

<Tabs>
  <Tab title="iOS">
    | Field            | Type           | Description                                                                                  |
    | ---------------- | -------------- | -------------------------------------------------------------------------------------------- |
    | `status`         | `StepUpStatus` | `.continue`, `.underReview`, or `.blocked`.                                                  |
    | `challengeID`    | `String`       | Server-side identifier for this attempt.                                                     |
    | `currentStep`    | `String?`      | Next server step (e.g. `"verify_sms"`, `"verify_email"`, `"completed"`). `nil` when blocked. |
    | `requestedScope` | `String`       | The scope passed to `requestStepUp`.                                                         |
  </Tab>

  <Tab title="Android">
    | Field            | Type                  | Description                                                                                                                 |
    | ---------------- | --------------------- | --------------------------------------------------------------------------------------------------------------------------- |
    | `status`         | `PreludeStepUpStatus` | `CONTINUE`, `UNDER_REVIEW`, or `BLOCKED`.                                                                                   |
    | `challengeId`    | `String`              | Server-side identifier for this attempt.                                                                                    |
    | `currentStep`    | `String?`             | Next server step (e.g. `"verify_sms"`, `"verify_email"`, `"completed"`). `null` when blocked or omitted by an older server. |
    | `requestedScope` | `String`              | The scope passed to `requestStepUp`.                                                                                        |
  </Tab>

  <Tab title="Flutter">
    | Field            | Type           | Description                                                                                   |
    | ---------------- | -------------- | --------------------------------------------------------------------------------------------- |
    | `status`         | `StepUpStatus` | `continueStep`, `underReview`, or `blocked`.                                                  |
    | `challengeID`    | `String`       | Server-side identifier for this attempt.                                                      |
    | `currentStep`    | `String?`      | Next server step (e.g. `"verify_sms"`, `"verify_email"`, `"completed"`). `null` when blocked. |
    | `requestedScope` | `String`       | The scope passed to `requestStepUp`.                                                          |
  </Tab>

  <Tab title="React Native">
    | Field            | Type             | Description                                                                                   |
    | ---------------- | ---------------- | --------------------------------------------------------------------------------------------- |
    | `status`         | `StepUpStatus`   | `"continue"`, `"review"`, or `"block"`.                                                       |
    | `challengeID`    | `string`         | Server-side identifier for this attempt.                                                      |
    | `currentStep`    | `string \| null` | Next server step (e.g. `"verify_sms"`, `"verify_email"`, `"completed"`). `null` when blocked. |
    | `requestedScope` | `string`         | The scope passed to `requestStepUp`.                                                          |
  </Tab>
</Tabs>

## Send the step-up OTP

When `challenge.currentStep` is `verify_email` or `verify_sms`, fire delivery by passing the challenge to `sendStepUpOTP`. The SDK posts to `/otp` and the user receives the code on the corresponding identifier.

<Tabs>
  <Tab title="iOS">
    ```swift theme={null}
    if challenge.currentStep == "verify_email"
        || challenge.currentStep == "verify_sms" {
        try await client.sendStepUpOTP(challenge)
    }
    ```

    Throws `PreludeAuthError.invalidChallengeToken` if `challenge` is blocked (no token to sign with).
  </Tab>

  <Tab title="Android">
    ```kotlin theme={null}
    if (challenge.currentStep in setOf("verify_email", "verify_sms")) {
        client.sendStepUpOTP(challenge)
    }
    ```

    Throws `PreludeAuthError.InvalidChallengeToken` if the challenge is blocked.
  </Tab>

  <Tab title="Flutter">
    ```dart theme={null}
    if (challenge.currentStep == 'verify_email' ||
        challenge.currentStep == 'verify_sms') {
      await client.sendStepUpOTP(challenge);
    }
    ```

    Throws `InvalidChallengeTokenException` if the challenge is blocked.
  </Tab>

  <Tab title="React Native">
    ```ts theme={null}
    if (
      challenge.currentStep === "verify_email" ||
      challenge.currentStep === "verify_sms"
    ) {
      await client.sendStepUpOTP(challenge);
    }
    ```

    Throws `InvalidChallengeTokenError` if the challenge is blocked.
  </Tab>
</Tabs>

Code delivery is intentionally caller-driven on every platform: the SDK doesn't fire `/otp` on its own, so your UI controls timing and can expose a "Resend code" affordance by calling `sendStepUpOTP` again with the same challenge.

## Submit an OTP step

Submit the code the user entered. The SDK signs `/otp/check` with a challenge-scoped DPoP proof, advances the challenge, and — when the flow reaches `completed` — automatically refreshes the session so the next access token carries the granted scope.

When `submitStepUpOTP` returns a non-null next challenge whose `currentStep` is another OTP delivery (for example, `verify_email → verify_sms`), call `sendStepUpOTP(next)` again to fire the next code before prompting the user.

<Tabs>
  <Tab title="iOS">
    ```swift theme={null}
    do {
        let next = try await client.submitStepUpOTP(challenge, code: "123456")

        if let next {
            // Multi-step flow: drive `next.currentStep` and call
            // submitStepUpOTP again with `next`.
        } else {
            // Flow complete. The session has been refreshed and the
            // new access token includes `transfer:write`.
        }
    } catch PreludeAuthError.invalidOTPCode {
        // Wrong code — you can keep retrying with the same `challenge`
        // until the server's retry limit is reached.
    } catch PreludeAuthError.invalidChallengeToken {
        // Challenge expired or unusable. Restart with requestStepUp.
    }
    ```
  </Tab>

  <Tab title="Android">
    ```kotlin theme={null}
    try {
        val next = client.submitStepUpOTP(challenge, "123456")

        if (next != null) {
            // Multi-step flow: drive `next.currentStep` and call
            // submitStepUpOTP again with `next`.
        } else {
            // Flow complete. The session has been refreshed and the
            // new access token includes `transfer:write`.
        }
    } catch (e: PreludeAuthError.InvalidOTPCode) {
        // Wrong code — you can keep retrying with the same `challenge`
        // until the server's retry limit is reached.
    } catch (e: PreludeAuthError.InvalidChallengeToken) {
        // Challenge expired or unusable. Restart with requestStepUp.
    }
    ```
  </Tab>

  <Tab title="Flutter">
    ```dart theme={null}
    try {
      final next = await client.submitStepUpOTP(challenge, '123456');

      if (next != null) {
        // Multi-step flow: drive `next.currentStep` and call
        // submitStepUpOTP again with `next`.
      } else {
        // Flow complete. The session has been refreshed and the
        // new access token includes `transfer:write`.
      }
    } on InvalidOTPCodeException {
      // Wrong code — you can keep retrying with the same `challenge`
      // until the server's retry limit is reached.
    } on InvalidChallengeTokenException {
      // Challenge expired or unusable. Restart with requestStepUp.
    }
    ```
  </Tab>

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

    try {
      const next = await client.submitStepUpOTP(challenge, "123456");

      if (next !== null) {
        // Multi-step flow: drive `next.currentStep` and call
        // submitStepUpOTP again with `next`.
      } else {
        // Flow complete. The session has been refreshed and the
        // new access token includes `transfer:write`.
      }
    } catch (e) {
      if (e instanceof InvalidOTPCodeError) {
        // Wrong code — you can keep retrying with the same `challenge`
        // until the server's retry limit is reached.
      } else if (e instanceof InvalidChallengeTokenError) {
        // Challenge expired or unusable. Restart with requestStepUp.
      } else {
        throw e;
      }
    }
    ```
  </Tab>
</Tabs>

## Automatic completion

When the last step is completed, the SDK:

1. Refreshes the session with the challenge token, minting an access token that carries the granted scope. Concurrent `refresh()` callers piggyback on the same in-flight refresh.
2. Clears the active step-up handle.
3. Returns no further step from `submitStepUpOTP` to signal the flow is done.

After that, any protected call you make uses the scoped token automatically.

<Accordion title="Try it" icon="flask">
  This example builds on the project from [Introduction](/session/documentation/frontend-sdks/mobile/introduction). Make sure you have a working OTP login first ([OTP Login](/session/documentation/frontend-sdks/mobile/otp)).

  **1. Create a mock hook**

  Go to [mockerapi.com](https://mockerapi.com/mock-api-generator) and create a mock API that returns the following JSON on `POST`:

  ```json theme={null}
  {
    "status": "review",
    "granted_for": 300,
    "grant_mode": "single-use",
    "steps": [
      {
        "order": 1,
        "key": "verify_sms",
        "expiration_duration": 600
      }
    ]
  }
  ```

  Copy the generated mock URL.

  **2. Configure step-up**

  ```bash theme={null}
  curl -X POST https://api.prelude.dev/v2/session/apps/${APP_ID}/config/stepup \
    -H "Authorization: Bearer ${MANAGEMENT_API_KEY}" \
    -H "Content-Type: application/json" \
    -d '{
      "jwks_url": "https://example.com/.well-known/jwks.json",
      "step_keys": [],
      "allowed_scopes": [
        {
          "scope": "transfer:write",
          "mode": "delegated",
          "delegated": { "delegation_hook": "YOUR_MOCK_URL" }
        }
      ]
    }'
  ```

  **3. Register the scope**

  ```bash theme={null}
  curl -X POST https://api.prelude.dev/v2/session/apps/${APP_ID}/config/scopes \
    -H "Authorization: Bearer ${MANAGEMENT_API_KEY}" \
    -H "Content-Type: application/json" \
    -d '{ "scope": "transfer:write" }'
  ```

  **4. Replace your entry file**

  <Tabs>
    <Tab title="iOS">
      Replace `ContentView.swift`:

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

      private let appID = "YOUR_APP_ID"

      enum Stage { case login, code, logged, stepUpOTP, done }

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

          @Published var view: Stage = .login
          @Published var error: String?
          @Published var profile: PreludeProfile?

          var challenge: StepUpChallenge?

          func sendLoginOTP(to phone: String) async {
              error = nil
              do {
                  try await client.startOTPLogin(
                      StartOTPLoginOptions(
                          identifier: PreludeIdentifier(type: .phoneNumber, value: phone)
                      )
                  )
                  view = .code
              } catch { self.error = error.localizedDescription }
          }

          func checkLoginOTP(_ code: String) async {
              error = nil
              do {
                  let user = try await client.checkOTP(code)
                  profile = user.profile
                  view = .logged
              } catch PreludeAuthError.invalidOTPCode {
                  error = "Wrong code."
              } catch { self.error = error.localizedDescription }
          }

          func requestStepUp() async {
              error = nil
              do {
                  let c = try await client.requestStepUp(scope: "transfer:write")
                  if c.status == .blocked {
                      error = "Your hook denied this scope."
                      return
                  }
                  if c.currentStep == "verify_email" || c.currentStep == "verify_sms" {
                      try await client.sendStepUpOTP(c)
                  }
                  challenge = c
                  view = .stepUpOTP
              } catch { self.error = error.localizedDescription }
          }

          func submitStepUpOTP(_ code: String) async {
              guard let c = challenge else { return }
              error = nil
              do {
                  if let next = try await client.submitStepUpOTP(c, code: code) {
                      if next.currentStep == "verify_email" || next.currentStep == "verify_sms" {
                          try await client.sendStepUpOTP(next)
                      }
                      challenge = next
                  } else {
                      // Completed — refresh to pick up the scoped token.
                      let user = try await client.refresh()
                      profile = user.profile
                      view = .done
                  }
              } catch PreludeAuthError.invalidOTPCode {
                  error = "Wrong code."
              } catch { self.error = error.localizedDescription }
          }
      }

      struct ContentView: View {
          @StateObject private var model = StepUpModel()
          @State private var phone = ""
          @State private var loginCode = ""
          @State private var stepUpCode = ""

          var body: some View {
              VStack(spacing: 12) {
                  Text("Step-Up Demo").font(.title3)
                  if let error = model.error {
                      Text(error).foregroundStyle(.red).font(.caption)
                  }

                  switch model.view {
                  case .login:
                      TextField("+14155551234", text: $phone).keyboardType(.phonePad)
                      Button("Send OTP") {
                          Task { await model.sendLoginOTP(to: phone) }
                      }.buttonStyle(.borderedProminent)
                  case .code:
                      TextField("Login code", text: $loginCode).keyboardType(.numberPad)
                      Button("Verify") {
                          Task { await model.checkLoginOTP(loginCode) }
                      }.buttonStyle(.borderedProminent)
                  case .logged:
                      Text("Logged in.").font(.headline)
                      Button("Request transfer:write") {
                          Task { await model.requestStepUp() }
                      }.buttonStyle(.borderedProminent)
                  case .stepUpOTP:
                      Text("Enter the step-up code.")
                      TextField("Code", text: $stepUpCode).keyboardType(.numberPad)
                      Button("Verify") {
                          Task { await model.submitStepUpOTP(stepUpCode) }
                      }.buttonStyle(.borderedProminent)
                  case .done:
                      Text("Scope granted.").font(.headline)
                      if let p = model.profile {
                          Text("session: \(p.sessionID ?? "-")")
                              .font(.caption.monospaced())
                      }
                  }
              }
              .textFieldStyle(.roundedBorder)
              .padding()
          }
      }
      ```
    </Tab>

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

      ```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.KeyboardOptions
      import androidx.compose.ui.text.input.KeyboardType
      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 StepUpView { Login, Code, Logged, StepUpOtp, Done }

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

          var view by mutableStateOf(StepUpView.Login); private set
          var error by mutableStateOf<String?>(null); private set
          var profile by mutableStateOf<PreludeProfile?>(null); private set
          private var challenge: PreludeStepUpChallenge? = null

          fun sendLoginOtp(phone: String) {
              error = null
              viewModelScope.launch {
                  try {
                      client.startOTPLogin(
                          StartOTPLoginOptions(
                              identifier = PreludeIdentifier(
                                  type = PreludeIdentifierType.PHONE_NUMBER,
                                  value = phone,
                              ),
                          ),
                      )
                      view = StepUpView.Code
                  } catch (e: PreludeAuthError) { error = e.message }
              }
          }

          fun checkLoginOtp(code: String) {
              error = null
              viewModelScope.launch {
                  try {
                      val user = client.checkOTP(code)
                      profile = user.profile
                      view = StepUpView.Logged
                  } catch (e: PreludeAuthError.InvalidOTPCode) { error = "Wrong code." }
                  catch (e: PreludeAuthError) { error = e.message }
              }
          }

          fun requestStepUp() {
              error = null
              viewModelScope.launch {
                  try {
                      val c = client.requestStepUp("transfer:write")
                      if (c.status == PreludeStepUpStatus.BLOCKED) {
                          error = "Your hook denied this scope."
                          return@launch
                      }
                      if (c.currentStep in setOf("verify_email", "verify_sms")) {
                          client.sendStepUpOTP(c)
                      }
                      challenge = c
                      view = StepUpView.StepUpOtp
                  } catch (e: PreludeAuthError) { error = e.message }
              }
          }

          fun submitStepUpOtp(code: String) {
              val c = challenge ?: return
              error = null
              viewModelScope.launch {
                  try {
                      val next = client.submitStepUpOTP(c, code)
                      if (next != null) {
                          if (next.currentStep in setOf("verify_email", "verify_sms")) {
                              client.sendStepUpOTP(next)
                          }
                          challenge = next
                      } else {
                          profile = client.refresh().profile
                          view = StepUpView.Done
                      }
                  } catch (e: PreludeAuthError.InvalidOTPCode) { error = "Wrong code." }
                  catch (e: PreludeAuthError) { error = e.message }
              }
          }
      }

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

                          Column(Modifier.fillMaxSize().padding(24.dp)) {
                              Text("Step-Up Demo", style = MaterialTheme.typography.titleMedium)
                              Spacer(Modifier.height(12.dp))
                              vm.error?.let {
                                  Text(it, color = MaterialTheme.colorScheme.error)
                                  Spacer(Modifier.height(8.dp))
                              }
                              when (vm.view) {
                                  StepUpView.Login -> {
                                      OutlinedTextField(
                                          phone, { phone = it },
                                          label = { Text("+14155551234") },
                                          keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
                                          modifier = Modifier.fillMaxWidth(),
                                      )
                                      Spacer(Modifier.height(8.dp))
                                      Button(onClick = { vm.sendLoginOtp(phone) }) { Text("Send OTP") }
                                  }
                                  StepUpView.Code -> {
                                      OutlinedTextField(
                                          loginCode, { loginCode = it },
                                          label = { Text("Login code") },
                                          keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
                                          modifier = Modifier.fillMaxWidth(),
                                      )
                                      Spacer(Modifier.height(8.dp))
                                      Button(onClick = { vm.checkLoginOtp(loginCode) }) { Text("Verify") }
                                  }
                                  StepUpView.Logged -> {
                                      Text("Logged in.", style = MaterialTheme.typography.titleSmall)
                                      Spacer(Modifier.height(8.dp))
                                      Button(onClick = { vm.requestStepUp() }) {
                                          Text("Request transfer:write")
                                      }
                                  }
                                  StepUpView.StepUpOtp -> {
                                      Text("Enter the step-up code.")
                                      OutlinedTextField(
                                          stepUpCode, { stepUpCode = it },
                                          label = { Text("Code") },
                                          keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
                                          modifier = Modifier.fillMaxWidth(),
                                      )
                                      Spacer(Modifier.height(8.dp))
                                      Button(onClick = { vm.submitStepUpOtp(stepUpCode) }) { Text("Verify") }
                                  }
                                  StepUpView.Done -> {
                                      Text("Scope granted.", style = MaterialTheme.typography.titleSmall)
                                      Text("session: ${vm.profile?.sessionId ?: "-"}")
                                  }
                              }
                          }
                      }
                  }
              }
          }
      }
      ```

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

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

      ```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 _Screen { login, code, logged, stepUpOtp, 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 _loginCode = TextEditingController();
        final _stepUpCode = TextEditingController();

        _Screen _view = _Screen.login;
        StepUpChallenge? _challenge;
        PreludeProfile? _profile;
        String? _error;

        Future<void> _sendLoginOtp() async {
          setState(() => _error = null);
          try {
            await client.startOTPLogin(
              StartOTPLoginOptions(
                identifier: PreludeIdentifier.phoneNumber(_phone.text),
              ),
            );
            setState(() => _view = _Screen.code);
          } on PreludeAuthException catch (e) {
            setState(() => _error = e.message);
          }
        }

        Future<void> _checkLoginOtp() async {
          setState(() => _error = null);
          try {
            final user = await client.checkOTP(_loginCode.text);
            setState(() {
              _profile = user.profile;
              _view = _Screen.logged;
            });
          } on InvalidOTPCodeException {
            setState(() => _error = 'Wrong code.');
          } on PreludeAuthException catch (e) {
            setState(() => _error = e.message);
          }
        }

        Future<void> _requestStepUp() async {
          setState(() => _error = null);
          try {
            final c = await client.requestStepUp(scope: 'transfer:write');
            if (c.status == StepUpStatus.blocked) {
              setState(() => _error = 'Your hook denied this scope.');
              return;
            }
            if (c.currentStep == 'verify_email' ||
                c.currentStep == 'verify_sms') {
              await client.sendStepUpOTP(c);
            }
            setState(() {
              _challenge = c;
              _view = _Screen.stepUpOtp;
            });
          } on PreludeAuthException catch (e) {
            setState(() => _error = e.message);
          }
        }

        Future<void> _submitStepUpOtp() async {
          final c = _challenge;
          if (c == null) return;
          setState(() => _error = null);
          try {
            final next = await client.submitStepUpOTP(c, _stepUpCode.text);
            if (next != null) {
              if (next.currentStep == 'verify_email' ||
                  next.currentStep == 'verify_sms') {
                await client.sendStepUpOTP(next);
              }
              setState(() => _challenge = next);
            } else {
              final user = await client.refresh();
              setState(() {
                _profile = user.profile;
                _view = _Screen.done;
              });
            }
          } on InvalidOTPCodeException {
            setState(() => _error = 'Wrong code.');
          } on PreludeAuthException catch (e) {
            setState(() => _error = e.message);
          }
        }

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

        @override
        Widget build(BuildContext context) {
          Widget body;
          switch (_view) {
            case _Screen.login:
              body = Column(mainAxisSize: MainAxisSize.min, children: [
                TextField(
                  controller: _phone,
                  keyboardType: TextInputType.phone,
                  decoration: const InputDecoration(labelText: '+14155551234'),
                ),
                const SizedBox(height: 12),
                FilledButton(onPressed: _sendLoginOtp, child: const Text('Send OTP')),
              ]);
            case _Screen.code:
              body = Column(mainAxisSize: MainAxisSize.min, children: [
                TextField(
                  controller: _loginCode,
                  keyboardType: TextInputType.number,
                  decoration: const InputDecoration(labelText: 'Login code'),
                ),
                const SizedBox(height: 12),
                FilledButton(onPressed: _checkLoginOtp, child: const Text('Verify')),
              ]);
            case _Screen.logged:
              body = Column(mainAxisSize: MainAxisSize.min, children: [
                const Text('Logged in.'),
                const SizedBox(height: 12),
                FilledButton(
                  onPressed: _requestStepUp,
                  child: const Text('Request transfer:write'),
                ),
              ]);
            case _Screen.stepUpOtp:
              body = Column(mainAxisSize: MainAxisSize.min, children: [
                const Text('Enter the step-up code.'),
                TextField(
                  controller: _stepUpCode,
                  keyboardType: TextInputType.number,
                  decoration: const InputDecoration(labelText: 'Code'),
                ),
                const SizedBox(height: 12),
                FilledButton(onPressed: _submitStepUpOtp, child: const Text('Verify')),
              ]);
            case _Screen.done:
              body = Column(mainAxisSize: MainAxisSize.min, children: [
                const Text('Scope granted.', style: TextStyle(fontSize: 18)),
                const SizedBox(height: 8),
                Text('session: ${_profile?.sessionID ?? "-"}',
                    style: const TextStyle(fontFamily: 'monospace')),
              ]);
          }

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

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

      ```tsx app/index.tsx theme={null}
      import {
        Endpoint,
        InvalidOTPCodeError,
        PreludeIdentifier,
        PreludeProfile,
        PreludeAuthClient,
        PreludeAuthError,
        StepUpChallenge,
      } 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 Stage = "login" | "code" | "logged" | "stepUpOtp" | "done";

      export default function Home() {
        const clientRef = useRef<PreludeAuthClient | null>(null);
        const [stage, setStage] = useState<Stage>("login");
        const [phone, setPhone] = useState("");
        const [loginCode, setLoginCode] = useState("");
        const [stepUpCode, setStepUpCode] = useState("");
        const [profile, setProfile] = useState<PreludeProfile | null>(null);
        const challengeRef = useRef<StepUpChallenge | 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 run(fn: () => Promise<void>) {
          setError(null);
          try { await fn(); }
          catch (e) {
            if (e instanceof InvalidOTPCodeError) setError("Wrong code.");
            else if (e instanceof PreludeAuthError) setError(e.message);
            else setError(String(e));
          }
        }

        const sendLoginOtp = () => run(async () => {
          await clientRef.current!.startOTPLogin({
            identifier: PreludeIdentifier.phoneNumber(phone),
          });
          setStage("code");
        });

        const checkLoginOtp = () => run(async () => {
          const user = await clientRef.current!.checkOTP(loginCode);
          setProfile(user.profile);
          setStage("logged");
        });

        const requestStepUp = () => run(async () => {
          const c = await clientRef.current!.requestStepUp("transfer:write");
          if (c.status === "block") {
            setError("Your hook denied this scope.");
            return;
          }
          challengeRef.current = c;
          setStage("stepUpOtp");
        });

        const submitStepUp = () => run(async () => {
          const c = challengeRef.current;
          if (!c) return;
          const next = await clientRef.current!.submitStepUpOTP(c, stepUpCode);
          if (next !== null) {
            challengeRef.current = next;
          } else {
            const user = await clientRef.current!.refresh();
            setProfile(user.profile);
            setStage("done");
          }
        });

        return (
          <View style={{ padding: 24, gap: 8 }}>
            <Text style={{ fontSize: 18 }}>Step-Up Demo</Text>
            {error && <Text style={{ color: "red" }}>{error}</Text>}
            {stage === "login" && (
              <>
                <TextInput
                  placeholder="+14155551234"
                  value={phone}
                  onChangeText={setPhone}
                  keyboardType="phone-pad"
                  style={{ borderWidth: 1, padding: 8 }}
                />
                <Button title="Send OTP" onPress={sendLoginOtp} />
              </>
            )}
            {stage === "code" && (
              <>
                <TextInput
                  placeholder="Login code"
                  value={loginCode}
                  onChangeText={setLoginCode}
                  keyboardType="number-pad"
                  style={{ borderWidth: 1, padding: 8 }}
                />
                <Button title="Verify" onPress={checkLoginOtp} />
              </>
            )}
            {stage === "logged" && (
              <>
                <Text style={{ fontWeight: "600" }}>Logged in.</Text>
                <Button title="Request transfer:write" onPress={requestStepUp} />
              </>
            )}
            {stage === "stepUpOtp" && (
              <>
                <Text>Enter the step-up code.</Text>
                <TextInput
                  placeholder="Code"
                  value={stepUpCode}
                  onChangeText={setStepUpCode}
                  keyboardType="number-pad"
                  style={{ borderWidth: 1, padding: 8 }}
                />
                <Button title="Verify" onPress={submitStepUp} />
              </>
            )}
            {stage === "done" && (
              <>
                <Text style={{ fontWeight: "600" }}>Scope granted.</Text>
                <Text>session: {profile?.sessionID ?? "-"}</Text>
              </>
            )}
          </View>
        );
      }
      ```

      Configure your `prld:pwd:write` (or `transfer:write`) step-up policy so the server delivers the OTP as part of `/stepup/request`. Call `client.sendStepUpOTP(challenge)` when `challenge.currentStep` is `verify_email` or `verify_sms` to trigger OTP delivery — see [Send the step-up OTP](#send-the-step-up-otp).
    </Tab>
  </Tabs>

  Build and run, log in with your phone number, then tap **Request transfer:write**. You'll receive a second OTP to complete the challenge. After verification, the access token will include the `transfer:write` scope.
</Accordion>
