Skip to main content
Let an authenticated user change their password from the account area. The SDK acquires the prld:pwd:write session scope via a short OTP challenge, then calls the reset endpoint — the scope is consumed atomically on save. See the Logged-in Password Reset guide for the backend configuration (direct step-up on prld:pwd:write). This page focuses on the frontend integration.

Flow at a glance

  1. Request the prld:pwd:write scope via requestStepUp.
  2. Drive the OTP step that Prelude returns (verify_email or verify_sms, depending on the user’s identifiers).
  3. Once the SDK has auto-refreshed the session with the granted scope, call client.resetPassword({ password }).

Example

import { PrldErrors } from "@prelude.so/js-sdk";

async function resetPassword(newPassword) {
  const { status } = await client.requestStepUp({
    scope: "prld:pwd:write",
    onChallenge: async (info) => {
      if (info.currentStep === "verify_email" || info.currentStep === "verify_sms") {
        // Send the OTP — the user will enter the code in your UI and
        // your form handler will call client.otpCheck(...) with it.
        await client.otpCreate({ challengeId: info.challengeId });
      }
    },
  });

  if (status === "block") {
    throw new Error("Password reset denied");
  }

  // The SDK refreshes the session automatically once the last step is
  // completed, so the access token now carries prld:pwd:write.
  await client.resetPassword({ password: newPassword });
}
For the full step-up SDK surface (otpCreate, otpCheck, otpRetry, challenge callback shape), see Step-Up.
This example builds on the project from Introduction. Make sure you have a working password login first (Password) and that the user you log in with has an email_address or phone_number identifier.1. Configure direct step-up for prld:pwd:write
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": "",
    "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 }
          ]
        }
      }
    ]
  }'
2. Register the scope
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" }'
3. Replace src/App.jsx
src/App.jsx
import "@picocss/pico";
import { useState } from "react";
import { PrldSessionClient, PrldErrors } from "@prelude.so/js-sdk";

const client = new PrldSessionClient({ domain: `${import.meta.env.VITE_APP_ID}.session.prelude.dev` });

export default function App() {
  const [view, setView] = useState("login"); // login | logged | stepup-otp | new-password | done
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const [otpCode, setOtpCode] = useState("");
  const [newPassword, setNewPassword] = useState("");
  const [challengeId, setChallengeId] = useState(null);
  const [error, setError] = useState(null);

  // ── Login ──────────────────────────────────────────────────

  const handleLogin = async (e) => {
    e.preventDefault();
    setError(null);
    try {
      await client.loginWithPassword({
        identifier: { type: "email_address", value: email },
        password,
      });
      await client.refresh();
      setView("logged");
    } catch (err) {
      setError(err.message);
    }
  };

  // ── Step-up for prld:pwd:write ─────────────────────────────

  const handleChallenge = (info) => {
    setChallengeId(info.challengeId);
    if (info.currentStep === "completed") {
      // Scope granted — session already refreshed by the SDK.
      setView("new-password");
    } else if (info.currentStep === "verify_email" || info.currentStep === "verify_sms") {
      setView("stepup-otp");
      client.otpCreate({ challengeId: info.challengeId });
    }
  };

  const handleStartReset = async () => {
    setError(null);
    try {
      const { status } = await client.requestStepUp({
        scope: "prld:pwd:write",
        onChallenge: handleChallenge,
      });
      if (status === "block") setError("Password reset denied.");
    } catch (err) {
      setError(err.message);
    }
  };

  const handleCheckOTP = async (e) => {
    e.preventDefault();
    setError(null);
    try {
      await client.otpCheck(challengeId, otpCode, handleChallenge);
    } catch (err) {
      if (err instanceof PrldErrors.BadCheckCode) setError("Wrong code.");
      else setError(err.message);
    }
  };

  const handleSubmitPassword = async (e) => {
    e.preventDefault();
    setError(null);
    try {
      await client.resetPassword({ password: newPassword });
      setView("done");
    } catch (err) {
      setError(err.message);
    }
  };

  // ── Render ─────────────────────────────────────────────────

  return (
    <>
      <style>{`body, #root { display: flex; justify-content: center; align-items: center; min-height: 100vh; margin: 0; padding: 0; }`}</style>
      <main style={{ textAlign: "center", maxWidth: "600px", width: "100%" }}>
        <h1>Password Reset Demo</h1>
        {error && <p role="alert" style={{ color: "var(--pico-color-red-500)" }}>{error}</p>}

        {view === "login" && (
          <form onSubmit={handleLogin}>
            <input type="email" placeholder="user@example.com" value={email} onChange={(e) => setEmail(e.target.value)} required />
            <input type="password" placeholder="Current password" value={password} onChange={(e) => setPassword(e.target.value)} required />
            <button type="submit">Log in</button>
          </form>
        )}

        {view === "logged" && (
          <article>
            <p>Logged in.</p>
            <button onClick={handleStartReset}>Reset password</button>
          </article>
        )}

        {view === "stepup-otp" && (
          <form onSubmit={handleCheckOTP}>
            <p>Enter the verification code we just sent you.</p>
            <input type="text" placeholder="OTP code" value={otpCode} onChange={(e) => setOtpCode(e.target.value)} required />
            <button type="submit">Verify</button>
            <button type="button" className="secondary" onClick={() => client.otpRetry()}>Resend</button>
          </form>
        )}

        {view === "new-password" && (
          <form onSubmit={handleSubmitPassword}>
            <p>Choose a new password.</p>
            <input type="password" placeholder="New password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} required />
            <button type="submit">Save</button>
          </form>
        )}

        {view === "done" && (
          <article>
            <p>Password updated. The `prld:pwd:write` scope has been consumed.</p>
          </article>
        )}
      </main>
    </>
  );
}
Run npm run dev, log in, then click Reset password. You’ll receive an OTP on the identifier type configured above. After verification, enter a new password — the prld:pwd:write scope is consumed atomically on save and removed from the session.