Skip to main content

Request a scope

Initiate a step-up flow for a given scope. The SDK handles challenge token caching, DPoP proofs, and automatic session refresh on completion.
import { PrldErrors } from "@prelude.so/js-sdk";

try {
  const { status } = await client.requestStepUp({
    scope: "transfer:write",
    metadata: { amount: "500", currency: "USD" },
    onChallenge: (info) => {
      // Called when a challenge is created or the scope is granted
      console.log(info.currentStep); // current step key, or "completed"
      console.log(info.steps);       // array of { order, key, done, expirationDuration }
      console.log(info.challengeId); // use this to drive OTP and continue flows
    },
  });

  if (status === "block") {
    // Scope denied by your backend
  }
  // "continue" → scope granted immediately (session refreshed automatically)
  // "review"   → challenge created, complete the steps below
} catch (error) {
  if (error instanceof PrldErrors.ScopeNotAllowed) {
    // Scope is not in the allowed_scopes configuration
  }
}
FieldRequiredDescription
scopeYesThe scope to request. Must match an allowed_scopes entry.
metadataNoKey-value pairs forwarded to your hook (e.g. transaction details).
onChallengeNoCallback receiving challenge info after each step transition.
The onChallenge callback receives a StepUpChallengeInfo object:
FieldTypeDescription
currentStepstringThe key of the current step, or "completed" when all steps are done.
scopeRequestedstringThe scope being requested.
challengeIdstringThe challenge ID — pass this to otpCreate and otpCheck.
stepsarrayAll steps with order, key, done, and expirationDuration.
userIdstringThe Prelude user ID.
sessionIdstringThe current session ID.

Complete a managed OTP step

When the current step is verify_sms or verify_email, use the OTP methods with the challengeId from onChallenge:
// Send the OTP
await client.otpCreate({ challengeId });

// Verify the code — advances to the next step automatically
await client.otpCheck(challengeId, "123456", (info) => {
  console.log(info.currentStep); // next step key, or "completed"
});
If the user didn’t receive the code:
await client.otpRetry();

Complete a custom step

For custom steps (e.g. kyc_review), your backend issues a verification token after the user completes the step on your side. Pass it to the SDK:
// verificationToken is the RS256 JWT issued by your backend
await client.continueStepUp(verificationToken, (info) => {
  console.log(info.currentStep); // next step key, or "completed"
});
The SDK extracts the challenge_id from the verification token, retrieves the cached challenge token, and sends both to Prelude.

Automatic completion

When the last step is completed, the SDK automatically:
  1. Refreshes the session with the challenge token
  2. Clears the step-up cache for that challenge
  3. Calls your onChallenge callback with currentStep: "completed"
The new access token from client.refresh() will include the granted scope. No manual refresh call is needed.
This example builds on the project from Introduction. Make sure you have a working OTP login first (OTP Login).1. Create a mock hookGo to mockerapi.com and create a new mock API that returns the following JSON on POST:
{
  "status": "review",
  "granted_for": 300,
  "grant_mode": "single-use",
  "steps": [
    {
      "order": 1,
      "key": "verify_sms",
      "expiration_duration": 600
    }
  ]
}
Copy the generated mock URL (e.g. https://free.mockerapi.com/mock/xxxxxxxx).2. Configure step-upCreate a step-up configuration pointing to your mock hook. The jwks_url can be any valid URL since we only use managed steps here:
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 '{
    "signal_hook_url": "YOUR_MOCK_URL",
    "jwks_url": "https://example.com/.well-known/jwks.json",
    "step_keys": [],
    "allowed_scopes": ["transfer:write"]
  }'
3. Add the scopeRegister the scope on your application:
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 src/App.jsx
src/App.jsx
import "@picocss/pico";
import { useState } from "react";
import { PrldSessionClient, PrldErrors, decode } 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 | code | logged | stepup-otp | done
  const [phone, setPhone] = useState("");
  const [code, setCode] = useState("");
  const [stepUpCode, setStepUpCode] = useState("");
  const [challengeId, setChallengeId] = useState(null);
  const [user, setUser] = useState(null);
  const [error, setError] = useState(null);

  // ── Login flow ──────────────────────────────────────────────

  const handleSendOTP = async (e) => {
    e.preventDefault();
    setError(null);
    try {
      await client.startOTPLogin({ identifier: { type: "phone_number", value: phone } });
      setView("code");
    } catch (err) {
      setError(err.message);
    }
  };

  const handleCheckLogin = async (e) => {
    e.preventDefault();
    setError(null);
    try {
      await client.checkOTP(code);
      const { user } = await client.refresh();
      setUser(user);
      setView("logged");
    } catch (err) {
      if (err instanceof PrldErrors.BadCheckCode) setError("Wrong code.");
      else setError(err.message);
    }
  };

  // ── Step-up flow ────────────────────────────────────────────

  const handleChallenge = (info) => {
    setChallengeId(info.challengeId);
    if (info.currentStep === "completed") {
      client.refresh().then(({ user }) => {
        setUser(user);
        setView("done");
      });
    } else if (info.currentStep === "verify_sms" || info.currentStep === "verify_email") {
      setView("stepup-otp");
      client.otpCreate({ challengeId: info.challengeId });
    }
  };

  const handleStepUp = async () => {
    setError(null);
    try {
      const { status } = await client.requestStepUp({
        scope: "transfer:write",
        onChallenge: handleChallenge,
      });
      if (status === "block") setError("Scope denied by hook.");
    } catch (err) {
      setError(err.message);
    }
  };

  const handleCheckStepUp = async (e) => {
    e.preventDefault();
    setError(null);
    try {
      await client.otpCheck(challengeId, stepUpCode, handleChallenge);
    } catch (err) {
      if (err instanceof PrldErrors.BadCheckCode) setError("Wrong code.");
      else 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>Step-Up Demo</h1>
        {error && <p role="alert" style={{ color: "var(--pico-color-red-500)" }}>{error}</p>}

        {view === "login" && (
          <form onSubmit={handleSendOTP}>
            <input type="tel" placeholder="+14155551234" value={phone} onChange={(e) => setPhone(e.target.value)} required />
            <button type="submit">Send OTP</button>
          </form>
        )}

        {view === "code" && (
          <form onSubmit={handleCheckLogin}>
            <input type="text" placeholder="Enter login code" value={code} onChange={(e) => setCode(e.target.value)} required />
            <button type="submit">Verify</button>
          </form>
        )}

        {view === "logged" && (
          <article>
            <p>Logged in. Access token scopes: <code>{decode(user.accessToken).claims.scope || "none"}</code></p>
            <button onClick={handleStepUp}>Request transfer:write scope</button>
          </article>
        )}

        {view === "stepup-otp" && (
          <form onSubmit={handleCheckStepUp}>
            <p>Step-up: verify your phone number</p>
            <input type="text" placeholder="Enter step-up code" value={stepUpCode} onChange={(e) => setStepUpCode(e.target.value)} required />
            <button type="submit">Verify</button>
            <button type="button" className="secondary" onClick={() => client.otpRetry()}>Resend</button>
          </form>
        )}

        {view === "done" && user && (
          <article>
            <p>Scope granted</p>
            <pre style={{ textAlign: "left", whiteSpace: "pre-wrap", wordBreak: "break-all" }}>
              {JSON.stringify(decode(user.accessToken).claims, null, 2)}
            </pre>
          </article>
        )}
      </main>
    </>
  );
}
Run npm run dev, log in with your phone number, then click Request transfer:write scope. You’ll receive a second OTP to complete the step-up challenge. After verification, the access token will include the transfer:write scope.