Skip to main content
Once a user is authenticated, you can let them change their password from the account area without asking for their current password and without forcing them to sign out. Prelude protects the reset endpoint with the prld:pwd:write session scope — the user acquires the scope by completing a short verification challenge (SMS or email OTP), then calls the reset password endpoint. This guide shows how to wire that flow using a direct step-up scope configuration: you declare the challenge inline on the scope entry and Prelude serves it without calling any backend hook.

How it works

Because the step-up response is configured inline on the scope entry (mode: "direct"), there is no need to run a delegation hook: no delegation_hook is involved and jwks_url can be left empty as long as every allowed_scopes entry uses direct mode.

Prerequisites

  • A Prelude account with access to the Session API
  • An Application ID (appID) — see Applications
  • Your Management API key for backend calls
  • Password login configured on the application — see Password Authentication
  • Users with an email_address or phone_number identifier (used for the OTP step)

Configure the step-up flow

1

Register the prld:pwd:write scope

Add prld:pwd:write to the application’s allowed scopes so it can be requested from the frontend:
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" }'
2

Create a direct step-up configuration

Create a step-up configuration that resolves prld:pwd:write directly. The jwks_url can stay empty because no delegated scope is configured.Because the step differs depending on the user’s identifier (verify_email for email, verify_sms for phone), we register two allowed_scopes entries for the same scope, each scoped to a different identifier type. The first entry that matches one of the user’s identifiers is used.
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 }
          ]
        }
      }
    ]
  }'
FieldDescription
allowed_scopes[].scopeScope this entry resolves.
allowed_scopes[].modedirect to serve a static decision, delegated to call a delegation hook.
allowed_scopes[].direct.identifier_typesIdentifier types the user must hold for this entry to match. Valid values: email_address, phone_number.
allowed_scopes[].direct.status / grant_mode / granted_for / stepsThe decision fields, flattened. Same shape as the step-up hook response, inline on the scope entry.
Entries are matched in declaration order. The first direct entry whose scope matches and whose identifier_types overlaps with the user’s identifiers wins. Put the preferred challenge at the top.
You can list multiple identifier types in a single entry when the same decision applies to all of them (e.g. a custom step that is identifier-agnostic). Here we need two entries because the step differs: verify_email for email users, verify_sms for phone users. If you only support one identifier type, provide a single entry and list just that type.
If no direct entry’s identifier_types match the user, Prelude falls back to a delegated entry for the same scope when one exists. Add one — pointing at your delegation hook — if you want to keep the flow alive for users whose identifier type isn’t covered by any direct entry.

Relationship with OTP login grant_change_password

The step-up path described above is for users who are already logged in and want to change their password from the account area. It is complementary to the grant_change_password flag on OTP login configurations, which covers a different entry point:
Entry pointMechanismWhen to use
Unlogged reset / account creationOTP login with grant_change_password: true attaches prld:pwd:write to the session at login timeThe user proves possession of the identifier via the login OTP itself, so requiring another step-up right after would be redundant
Logged-in resetStatic step-up on prld:pwd:writeThe user is already authenticated and we want a fresh proof of possession before changing the password
Both can — and usually should — coexist on the same application.

Trigger the flow from the frontend

Once the configuration above is in place, wire the flow into your frontend. See the Web SDK Password Reset guide for the full code example and a runnable Try it sandbox.

What happens server-side

  1. POST /v1/session/stepup/request with scope=prld:pwd:write looks up the app’s step-up configuration.
  2. Because prld:pwd:write has direct entries, Prelude picks the first entry whose identifier_types matches the user and returns its inline decision — no hook is invoked.
  3. The user completes the OTP step. On completion Prelude mints a step-up token which the SDK uses to refresh the session; the new access token carries prld:pwd:write.
  4. POST /v1/session/me/password/reset validates the scope, writes the new password, and atomically removes the scope from the session so it cannot be reused.
If you later want to upgrade the flow with custom signals — for example to deny the reset for suspicious contexts — switch the entry’s mode from direct to delegated and point it at a delegation hook. Direct and delegated scopes can coexist in the same configuration.

Constraints

RuleLimit
jwks_urlRequired when at least one allowed_scopes entry uses delegated mode. Can be empty when every entry uses direct mode.
direct.identifier_typesMust not be empty. Allowed values: email_address, phone_number.
(scope, identifier_type) pairs (direct)Must be unique across all direct entries.
Delegated entries per scopeAt most one. It is used as a fallback when no direct entry matches.
Response granted_for0 to 86400 seconds. For a password reset, keep it short (60–300 seconds is typical).
Response grant_modeUse single-use so the scope is attached to a single access token only.

What’s next?

Step-Up Authentication

Full overview of step-up, including dynamic decisions via the delegation hook.

Step-Up Hook

Response shape used by delegated scopes. The same fields are inlined on direct scope entries.