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

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

```javascript theme={null}
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
  }
}
```

| Field         | Required | Description                                                                                               |
| ------------- | -------- | --------------------------------------------------------------------------------------------------------- |
| `scope`       | Yes      | The scope to request. Must match an `allowed_scopes` entry.                                               |
| `metadata`    | No       | Key-value pairs forwarded to your [hook](/session/documentation/step-up-hook) (e.g. transaction details). |
| `onChallenge` | No       | Callback receiving challenge info after each step transition.                                             |

The `onChallenge` callback receives a `StepUpChallengeInfo` object:

| Field            | Type     | Description                                                            |
| ---------------- | -------- | ---------------------------------------------------------------------- |
| `currentStep`    | `string` | The key of the current step, or `"completed"` when all steps are done. |
| `scopeRequested` | `string` | The scope being requested.                                             |
| `challengeId`    | `string` | The challenge ID — pass this to `startOTP` and `checkOTP`.             |
| `steps`          | `array`  | All steps with `order`, `key`, `done`, and `expirationDuration`.       |
| `userId`         | `string` | The Prelude user ID.                                                   |
| `sessionId`      | `string` | The 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`:

```javascript theme={null}
// Send the OTP
await client.startOTP({ challengeId: challengeId });

// Verify the code — advances to the next step automatically
await client.checkOTP({
  code: "123456",
  challengeId: challengeId,
  onChallenge: (info) => {
    console.log(info.currentStep); // next step key, or "completed"
  },
});
```

If the user didn't receive the code:

```javascript theme={null}
await client.retryOTP();
```

## Complete a custom step

For [custom steps](/session/documentation/step-up-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:

```javascript theme={null}
// 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.

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

  **1. Create a mock hook**

  Go to [mockerapi.com](https://mockerapi.com/mock-api-generator) and create a new 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 (e.g. `https://free.mockerapi.com/mock/xxxxxxxx`).

  **2. Configure step-up**

  Create a step-up configuration pointing to your mock hook. The `jwks_url` can be any valid URL since we only use managed steps here:

  ```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. Add the scope**

  Register the scope on your application:

  ```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 `src/App.jsx`**

  ```jsx src/App.jsx theme={null}
  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.startOTP({ 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.startOTP({ 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.checkOTP({ code: stepUpCode, challengeId: challengeId, onChallenge: 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.retryOTP()}>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.
</Accordion>
