Let an authenticated user change their password from the account area. The SDK acquires theDocumentation Index
Fetch the complete documentation index at: https://docs.prelude.so/llms.txt
Use this file to discover all available pages before exploring further.
prld:pwd:write session scope via a short OTP challenge, then calls the change-password endpoint — the scope is consumed atomically on save.
See the Logged-in Change Password guide for the backend configuration (direct step-up on prld:pwd:write). This page focuses on the mobile integration.
Flow at a glance
- Request the
prld:pwd:writescope viarequestStepUp. - Fire OTP delivery for the returned challenge via
sendStepUpOTP— the user receives the code on the identifier the server picked (verify_emailorverify_sms). - Submit the code via
submitStepUpOTP. - Once
submitStepUpOTPreturns no further step, the SDK has refreshed the session with the granted scope. Callclient.changePassword.
changePassword consumes the scope atomically on save — the SDK invalidates the cached access token and runs a best-effort refresh so the next mint drops the now-spent scope. A leaked token therefore can’t change the password again without re-stepping up.
For the full step-up SDK surface, see Step-Up.
Example
- iOS
- Android
- Flutter
- React Native
import PreludeSession
func changePassword(
client: PreludeSessionClient,
enteredCode: String,
newPassword: String
) async throws {
// 1. Acquire the scope.
let challenge = try await client.requestStepUp(scope: "prld:pwd:write")
guard challenge.status != .blocked else {
throw PreludeSessionError.forbidden("Change password is not allowed")
}
// 2. Fire OTP delivery for the step the server picked.
if challenge.currentStep == "verify_email"
|| challenge.currentStep == "verify_sms" {
try await client.sendStepUpOTP(challenge)
}
// 3. Submit the code the user entered. In a real UI this is
// a separate submit handler bound to the user's input.
let next = try await client.submitStepUpOTP(challenge, code: enteredCode)
if next != nil {
// Multi-step flow — call sendStepUpOTP(next) for the next
// delivery step, then submitStepUpOTP again, until next == nil.
}
// 4. The SDK refreshed the session for us; the access token
// now carries `prld:pwd:write`. Save the new password.
try await client.changePassword(RedactedString(newPassword))
}
import so.prelude.android.session.*
suspend fun changePassword(
client: PreludeSessionClient,
enteredCode: String,
newPassword: String,
) {
// 1. Acquire the scope.
val challenge = client.requestStepUp("prld:pwd:write")
if (challenge.status == PreludeStepUpStatus.BLOCKED) {
throw PreludeSessionError.Forbidden("Change password is not allowed")
}
// 2. Fire OTP delivery for the step the server picked.
if (challenge.currentStep in setOf("verify_email", "verify_sms")) {
client.sendStepUpOTP(challenge)
}
// 3. Submit the code the user entered. In a real UI this is
// a separate submit handler bound to the user's input.
val next = client.submitStepUpOTP(challenge, enteredCode)
if (next != null) {
// Multi-step flow — call sendStepUpOTP(next) for the next
// delivery step, then submitStepUpOTP again, until next == null.
}
// 4. The SDK refreshed the session for us; the access token
// now carries `prld:pwd:write`. Save the new password.
client.changePassword(RedactedString(newPassword))
}
import 'package:prelude_flutter_session_sdk/prelude_flutter_session_sdk.dart';
Future<void> changePassword(
PreludeSessionClient client,
String enteredCode,
String newPassword,
) async {
// 1. Acquire the scope.
final challenge = await client.requestStepUp(scope: 'prld:pwd:write');
if (challenge.status == StepUpStatus.blocked) {
throw const ForbiddenException('Change password is not allowed');
}
// 2. Fire OTP delivery for the step the server picked.
if (challenge.currentStep == 'verify_email' ||
challenge.currentStep == 'verify_sms') {
await client.sendStepUpOTP(challenge);
}
// 3. Submit the code the user entered. In a real UI this is
// a separate submit handler bound to the user's input.
final next = await client.submitStepUpOTP(challenge, enteredCode);
if (next != null) {
// Multi-step flow — call sendStepUpOTP(next) for the next
// delivery step, then submitStepUpOTP again, until next == null.
}
// 4. The SDK refreshed the session for us; the access token
// now carries `prld:pwd:write`. Save the new password.
await client.changePassword(RedactedString(newPassword));
}
import {
ForbiddenError,
PreludeSessionClient,
RedactedString,
} from "@prelude.so/react-native-session-sdk";
async function changePassword(
client: PreludeSessionClient,
enteredCode: string,
newPassword: string,
) {
// 1. Acquire the scope.
const challenge = await client.requestStepUp("prld:pwd:write");
if (challenge.status === "block") {
throw new ForbiddenError("Change password is not allowed");
}
// 2. RN v0.1.0 doesn't expose sendStepUpOTP — configure the
// server-side policy to deliver the code as part of
// /stepup/request. See the note on the Step-Up page.
// 3. Submit the code the user entered. In a real UI this is
// a separate submit handler bound to the user's input.
const next = await client.submitStepUpOTP(challenge, enteredCode);
if (next !== null) {
// Multi-step flow — keep submitting until next === null.
}
// 4. The SDK refreshed the session for us; the access token
// now carries `prld:pwd:write`. Save the new password.
await client.changePassword(new RedactedString(newPassword));
}
Try it
Try it
Builds on the project from Introduction and a working password login (Password). The user you log in with must have an 2. Register the scope3. Replace your entry fileBuild and run, log in, then tap Change password. You’ll receive an OTP on the identifier type configured above. After verification, enter a new password — the
email_address or phone_number identifier.1. Configure direct step-up for prld:pwd:writecurl -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": "",
"step_keys": [],
"allowed_scopes": [
{
"scope": "prld:pwd:write",
"mode": "direct",
"direct": {
"identifier_types": ["email_address"],
"status": "review",
"granted_for": 300,
"grant_mode": "single-use",
"steps": [
{ "order": 1, "key": "verify_email", "expiration_duration": 600 }
]
}
},
{
"scope": "prld:pwd:write",
"mode": "direct",
"direct": {
"identifier_types": ["phone_number"],
"status": "review",
"granted_for": 300,
"grant_mode": "single-use",
"steps": [
{ "order": 1, "key": "verify_sms", "expiration_duration": 600 }
]
}
}
]
}'
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": "prld:pwd:write" }'
- iOS
- Android
- Flutter
- React Native
Replace
ContentView.swift:ContentView.swift
import SwiftUI
import PreludeSession
private let appID = "YOUR_APP_ID"
enum CPView { case login, logged, stepUpOTP, newPassword, done }
@MainActor
final class ChangePasswordModel: ObservableObject {
let client = try! PreludeSessionClient(
endpoint: .custom("https://\(appID).session.prelude.dev")
)
@Published var view: CPView = .login
@Published var error: String?
var challenge: StepUpChallenge?
func login(email: String, password: String) async {
error = nil
do {
_ = try await client.loginWithPassword(
LoginWithPasswordOptions(emailAddress: email, password: password)
)
view = .logged
} catch { self.error = error.localizedDescription }
}
func startChangePassword() async {
error = nil
do {
let c = try await client.requestStepUp(scope: "prld:pwd:write")
if c.status == .blocked {
error = "Couldn't change your password — please try again later."
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 submitOTP(_ code: String) async {
guard let c = challenge else { return }
error = nil
do {
if let next = try await client.submitStepUpOTP(c, code: code) {
challenge = next // unusual: more than one step
} else {
view = .newPassword // scope granted
}
} catch PreludeSessionError.invalidOTPCode {
error = "Wrong code."
} catch { self.error = error.localizedDescription }
}
func savePassword(_ newPassword: String) async {
error = nil
do {
try await client.changePassword(RedactedString(newPassword))
view = .done
} catch { self.error = error.localizedDescription }
}
}
struct ContentView: View {
@StateObject private var model = ChangePasswordModel()
@State private var email = ""
@State private var password = ""
@State private var otp = ""
@State private var newPassword = ""
var body: some View {
VStack(spacing: 12) {
Text("Change Password Demo").font(.title3)
if let error = model.error {
Text(error).foregroundStyle(.red).font(.caption)
}
switch model.view {
case .login:
TextField("user@example.com", text: $email)
.textInputAutocapitalization(.never)
.keyboardType(.emailAddress)
SecureField("Current password", text: $password)
Button("Log in") {
Task { await model.login(email: email, password: password) }
}.buttonStyle(.borderedProminent)
case .logged:
Text("Logged in.")
Button("Change password") {
Task { await model.startChangePassword() }
}.buttonStyle(.borderedProminent)
case .stepUpOTP:
Text("Enter the code we just sent you.")
TextField("OTP code", text: $otp).keyboardType(.numberPad)
Button("Verify") {
Task { await model.submitOTP(otp) }
}.buttonStyle(.borderedProminent)
case .newPassword:
Text("Choose a new password.")
SecureField("New password", text: $newPassword)
Button("Save") {
Task { await model.savePassword(newPassword) }
}.buttonStyle(.borderedProminent)
case .done:
Text("Password updated.").font(.headline)
Text("`prld:pwd:write` has been consumed.")
.font(.caption).foregroundStyle(.secondary)
}
}
.textFieldStyle(.roundedBorder)
.padding()
}
}
Replace (
MainActivity.kt:MainActivity.kt
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.text.input.PasswordVisualTransformation
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.session.*
import java.net.URL
private const val APP_ID = "YOUR_APP_ID"
enum class CpView { Login, Logged, StepUpOtp, NewPassword, Done }
class ChangePasswordViewModel(ctx: android.content.Context) : ViewModel() {
val client = PreludeSessionClient(
context = ctx,
baseUrl = URL("https://$APP_ID.session.prelude.dev"),
)
var view by mutableStateOf(CpView.Login); private set
var error by mutableStateOf<String?>(null); private set
private var challenge: PreludeStepUpChallenge? = null
fun login(email: String, password: String) {
error = null
viewModelScope.launch {
try {
client.loginWithPassword(
LoginWithPasswordOptions(identifier = email, password = password),
)
view = CpView.Logged
} catch (e: PreludeSessionError) { error = e.message }
}
}
fun startChangePassword() {
error = null
viewModelScope.launch {
try {
val c = client.requestStepUp("prld:pwd:write")
if (c.status == PreludeStepUpStatus.BLOCKED) {
error = "Couldn't change your password — please try again later."
return@launch
}
if (c.currentStep in setOf("verify_email", "verify_sms")) {
client.sendStepUpOTP(c)
}
challenge = c
view = CpView.StepUpOtp
} catch (e: PreludeSessionError) { error = e.message }
}
}
fun submitOtp(code: String) {
val c = challenge ?: return
error = null
viewModelScope.launch {
try {
val next = client.submitStepUpOTP(c, code)
if (next != null) {
challenge = next // unusual: more than one step
} else {
view = CpView.NewPassword // scope granted
}
} catch (e: PreludeSessionError.InvalidOTPCode) { error = "Wrong code." }
catch (e: PreludeSessionError) { error = e.message }
}
}
fun savePassword(newPassword: String) {
error = null
viewModelScope.launch {
try {
client.changePassword(RedactedString(newPassword))
view = CpView.Done
} catch (e: PreludeSessionError) { error = e.message }
}
}
}
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MaterialTheme {
Surface {
val vm: ChangePasswordViewModel = viewModel(factory = viewModelFactory(applicationContext))
var email by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var otp by remember { mutableStateOf("") }
var newPassword by remember { mutableStateOf("") }
Column(Modifier.fillMaxSize().padding(24.dp)) {
Text("Change Password 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) {
CpView.Login -> {
OutlinedTextField(email, { email = it },
label = { Text("user@example.com") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
modifier = Modifier.fillMaxWidth())
Spacer(Modifier.height(8.dp))
OutlinedTextField(password, { password = it },
label = { Text("Current password") },
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth())
Spacer(Modifier.height(12.dp))
Button(onClick = { vm.login(email, password) }) { Text("Log in") }
}
CpView.Logged -> {
Text("Logged in.")
Spacer(Modifier.height(8.dp))
Button(onClick = { vm.startChangePassword() }) {
Text("Change password")
}
}
CpView.StepUpOtp -> {
Text("Enter the code we just sent you.")
OutlinedTextField(otp, { otp = it },
label = { Text("OTP code") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth())
Spacer(Modifier.height(8.dp))
Button(onClick = { vm.submitOtp(otp) }) { Text("Verify") }
}
CpView.NewPassword -> {
Text("Choose a new password.")
OutlinedTextField(newPassword, { newPassword = it },
label = { Text("New password") },
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth())
Spacer(Modifier.height(8.dp))
Button(onClick = { vm.savePassword(newPassword) }) { Text("Save") }
}
CpView.Done -> {
Text("Password updated.", style = MaterialTheme.typography.titleSmall)
Text("`prld:pwd:write` has been consumed.",
style = MaterialTheme.typography.bodySmall)
}
}
}
}
}
}
}
}
viewModelFactory is the small helper defined in Introduction.)Replace
lib/main.dart:lib/main.dart
import 'package:flutter/material.dart';
import 'package:prelude_flutter_session_sdk/prelude_flutter_session_sdk.dart';
const appID = 'YOUR_APP_ID';
void main() => runApp(const MyApp());
enum _Screen { login, logged, stepUpOtp, newPassword, done }
class MyApp extends StatefulWidget {
const MyApp({super.key});
@override
State<MyApp> createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
late final client = PreludeSessionClient(
endpoint: Endpoint.custom('https://$appID.session.prelude.dev'),
);
final _email = TextEditingController();
final _password = TextEditingController();
final _otp = TextEditingController();
final _newPassword = TextEditingController();
_Screen _view = _Screen.login;
StepUpChallenge? _challenge;
String? _error;
Future<void> _login() async {
setState(() => _error = null);
try {
await client.loginWithPassword(
LoginWithPasswordOptions(
emailAddress: _email.text,
password: _password.text,
),
);
setState(() => _view = _Screen.logged);
} on PreludeSessionException catch (e) {
setState(() => _error = e.message);
}
}
Future<void> _startChangePassword() async {
setState(() => _error = null);
try {
final c = await client.requestStepUp(scope: 'prld:pwd:write');
if (c.status == StepUpStatus.blocked) {
setState(() => _error = 'Couldn\'t change your password — please try again later.');
return;
}
if (c.currentStep == 'verify_email' ||
c.currentStep == 'verify_sms') {
await client.sendStepUpOTP(c);
}
setState(() {
_challenge = c;
_view = _Screen.stepUpOtp;
});
} on PreludeSessionException catch (e) {
setState(() => _error = e.message);
}
}
Future<void> _submitOtp() async {
final c = _challenge;
if (c == null) return;
setState(() => _error = null);
try {
final next = await client.submitStepUpOTP(c, _otp.text);
if (next != null) {
setState(() => _challenge = next); // unusual: more than one step
} else {
setState(() => _view = _Screen.newPassword); // scope granted
}
} on InvalidOTPCodeException {
setState(() => _error = 'Wrong code.');
} on PreludeSessionException catch (e) {
setState(() => _error = e.message);
}
}
Future<void> _savePassword() async {
setState(() => _error = null);
try {
await client.changePassword(RedactedString(_newPassword.text));
setState(() => _view = _Screen.done);
} on PreludeSessionException catch (e) {
setState(() => _error = e.message);
}
}
@override
void dispose() {
_email.dispose();
_password.dispose();
_otp.dispose();
_newPassword.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: _email,
keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(labelText: 'user@example.com'),
),
const SizedBox(height: 8),
TextField(
controller: _password,
obscureText: true,
decoration: const InputDecoration(labelText: 'Current password'),
),
const SizedBox(height: 12),
FilledButton(onPressed: _login, child: const Text('Log in')),
]);
case _Screen.logged:
body = Column(mainAxisSize: MainAxisSize.min, children: [
const Text('Logged in.'),
const SizedBox(height: 12),
FilledButton(
onPressed: _startChangePassword,
child: const Text('Change password'),
),
]);
case _Screen.stepUpOtp:
body = Column(mainAxisSize: MainAxisSize.min, children: [
const Text('Enter the code we just sent you.'),
TextField(
controller: _otp,
keyboardType: TextInputType.number,
decoration: const InputDecoration(labelText: 'OTP code'),
),
const SizedBox(height: 12),
FilledButton(onPressed: _submitOtp, child: const Text('Verify')),
]);
case _Screen.newPassword:
body = Column(mainAxisSize: MainAxisSize.min, children: [
const Text('Choose a new password.'),
TextField(
controller: _newPassword,
obscureText: true,
decoration: const InputDecoration(labelText: 'New password'),
),
const SizedBox(height: 12),
FilledButton(onPressed: _savePassword, child: const Text('Save')),
]);
case _Screen.done:
body = Column(mainAxisSize: MainAxisSize.min, children: const [
Text('Password updated.', style: TextStyle(fontSize: 18)),
SizedBox(height: 8),
Text('`prld:pwd:write` has been consumed.',
style: TextStyle(color: Colors.grey)),
]);
}
return MaterialApp(
home: Scaffold(
body: Padding(
padding: const EdgeInsets.all(24),
child: Center(
child: Column(mainAxisSize: MainAxisSize.min, children: [
const Text('Change Password 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,
]),
),
),
),
);
}
}
Replace Configure your
app/index.tsx:app/index.tsx
import {
Endpoint,
InvalidOTPCodeError,
PreludeSessionClient,
PreludeSessionError,
RedactedString,
StepUpChallenge,
} from "@prelude.so/react-native-session-sdk";
import { useEffect, useRef, useState } from "react";
import { Button, Text, TextInput, View } from "react-native";
const APP_ID = "YOUR_APP_ID";
type Stage = "login" | "logged" | "stepUpOtp" | "newPassword" | "done";
export default function Home() {
const clientRef = useRef<PreludeSessionClient | null>(null);
const [stage, setStage] = useState<Stage>("login");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [otp, setOtp] = useState("");
const [newPassword, setNewPassword] = useState("");
const challengeRef = useRef<StepUpChallenge | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const c = new PreludeSessionClient({
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 PreludeSessionError) setError(e.message);
else setError(String(e));
}
}
const login = () => run(async () => {
await clientRef.current!.loginWithPassword({
emailAddress: email,
password: new RedactedString(password),
});
setStage("logged");
});
const startChangePassword = () => run(async () => {
const c = await clientRef.current!.requestStepUp("prld:pwd:write");
if (c.status === "block") {
setError("Couldn't change your password — please try again later.");
return;
}
challengeRef.current = c;
setStage("stepUpOtp");
});
const submitOtp = () => run(async () => {
const c = challengeRef.current;
if (!c) return;
const next = await clientRef.current!.submitStepUpOTP(c, otp);
if (next !== null) {
challengeRef.current = next;
} else {
setStage("newPassword");
}
});
const savePassword = () => run(async () => {
await clientRef.current!.changePassword(new RedactedString(newPassword));
setStage("done");
});
return (
<View style={{ padding: 24, gap: 8 }}>
<Text style={{ fontSize: 18 }}>Change Password Demo</Text>
{error && <Text style={{ color: "red" }}>{error}</Text>}
{stage === "login" && (
<>
<TextInput
placeholder="user@example.com"
value={email}
onChangeText={setEmail}
autoCapitalize="none"
keyboardType="email-address"
style={{ borderWidth: 1, padding: 8 }}
/>
<TextInput
placeholder="Current password"
value={password}
onChangeText={setPassword}
secureTextEntry
style={{ borderWidth: 1, padding: 8 }}
/>
<Button title="Log in" onPress={login} />
</>
)}
{stage === "logged" && (
<Button title="Change password" onPress={startChangePassword} />
)}
{stage === "stepUpOtp" && (
<>
<Text>Enter the code we just sent you.</Text>
<TextInput
placeholder="OTP code"
value={otp}
onChangeText={setOtp}
keyboardType="number-pad"
style={{ borderWidth: 1, padding: 8 }}
/>
<Button title="Verify" onPress={submitOtp} />
</>
)}
{stage === "newPassword" && (
<>
<Text>Choose a new password.</Text>
<TextInput
placeholder="New password"
value={newPassword}
onChangeText={setNewPassword}
secureTextEntry
style={{ borderWidth: 1, padding: 8 }}
/>
<Button title="Save" onPress={savePassword} />
</>
)}
{stage === "done" && (
<>
<Text style={{ fontWeight: "600" }}>Password updated.</Text>
<Text>`prld:pwd:write` has been consumed.</Text>
</>
)}
</View>
);
}
prld:pwd:write step-up policy so the server delivers the OTP as part of /stepup/request — client.sendStepUpOTP is not yet exposed in the v0.1.0 React Native SDK.prld:pwd:write scope is consumed atomically on save and removed from the session.