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

# Custom Steps

> Add client-owned verification steps to step-up challenges.

Beyond Prelude's managed steps (`verify_sms`, `verify_email`), you can define **custom steps** that your own backend handles — KYC review, biometric verification, document upload, or any other process. When a custom step is reached, your backend verifies the user and issues a signed token that Prelude validates to advance the challenge.

Make sure you are familiar with the [Step-Up Authentication](/session/documentation/step-up-authentication) flow before reading this page.

## Prerequisites

* A working [step-up configuration](/session/documentation/step-up-authentication#configure-step-up)
* An RSA key pair for signing verification tokens
* A public **JWKS endpoint** exposing your public keys

## Setup

### 1. Register your custom step keys

Add your custom step keys and JWKS URL to the step-up configuration:

```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://api.example.com/.well-known/jwks.json",
    "step_keys": [
      {
        "key": "kyc_review",
        "description": "Identity verification via KYC provider"
      },
      {
        "key": "biometric_check",
        "description": "Face recognition verification"
      }
    ],
    "allowed_scopes": [
      {
        "scope": "transfer:write",
        "mode": "delegated",
        "delegated": {
          "delegation_hook": "https://api.example.com/hooks/stepup"
        }
      }
    ]
  }'
```

Step keys must only contain: `a-z`, `A-Z`, `0-9`, and `.-_:`.

### 2. Expose a JWKS endpoint

Your `jwks_url` must serve a standard [RFC 7517](https://tools.ietf.org/html/rfc7517) JSON Web Key Set containing the RSA public keys used to verify your verification tokens. Each key must include a `kid` (key ID).

```json theme={null}
{
  "keys": [
    {
      "kty": "RSA",
      "kid": "my-key-1",
      "use": "sig",
      "alg": "RS256",
      "n": "0vx7agoebGc...",
      "e": "AQAB"
    }
  ]
}
```

Prelude caches your JWKS for 10 minutes and automatically re-fetches on key-not-found to handle key rotation.

### 3. Return custom steps from your hook

In your [hook response](/session/documentation/step-up-hook#hook-response), include your custom step keys alongside any managed steps:

```json theme={null}
{
  "status": "review",
  "granted_for": 180,
  "grant_mode": "single-use",
  "steps": [
    {
      "order": 1,
      "key": "verify_sms",
      "expiration_duration": 600
    },
    {
      "order": 2,
      "key": "kyc_review",
      "expiration_duration": 300
    }
  ]
}
```

Steps are completed in order. In this example, the user first completes SMS verification (handled by Prelude), then your KYC review.

## Completing a custom step

When the challenge reaches a custom step, the user completes it on your side (your UI, your backend logic). Once verified, your backend issues a **verification token** and the user sends it to Prelude to advance the challenge.

### 1. Issue a verification token

Your backend creates an **RS256 JWT** signed with your private key:

```json theme={null}
{
  "sub": "usr_01kg1y07cze24ty0yw32jrwwf7",
  "exp": 1770885590,
  "nbf": 1770884990,
  "iat": 1770884990,
  "jti": "07b3bfc2-425d-4c78-af63-fd024918f0cf",
  "challenge_id": "cha_01kh8fh1hzeqvvfsmz7r1rn331",
  "key": "kyc_review",
  "status": "completed"
}
```

#### Token fields

| Field          | Type      | Description                                                                         |
| -------------- | --------- | ----------------------------------------------------------------------------------- |
| `sub`          | `string`  | The Prelude user ID. Must match the challenge token's `sub`.                        |
| `exp`          | `integer` | Expiration time (Unix timestamp).                                                   |
| `nbf`          | `integer` | Not-before time (Unix timestamp).                                                   |
| `iat`          | `integer` | Issued-at time (Unix timestamp).                                                    |
| `jti`          | `string`  | A unique JWT ID. **Must be unique across all tokens.** Prelude rejects reused JTIs. |
| `challenge_id` | `string`  | The challenge ID from the challenge token. Must match exactly.                      |
| `key`          | `string`  | The step key being completed. Must match the current step in the challenge.         |
| `status`       | `string`  | Must be `"completed"`.                                                              |

#### Token requirements

| Rule             | Detail                                                                                                  |
| ---------------- | ------------------------------------------------------------------------------------------------------- |
| Algorithm        | **RS256** only                                                                                          |
| JWT header `kid` | Required — must match a key ID in your JWKS endpoint                                                    |
| JTI uniqueness   | Each `jti` can only be used once. Replayed tokens are rejected with `token_reused`.                     |
| Field matching   | `sub` and `challenge_id` must match the challenge token. The `key` must correspond to the current step. |

#### Example (Node.js)

```javascript theme={null}
const jwt = require("jsonwebtoken");
const { v4: uuidv4 } = require("uuid");

function issueVerificationToken(userId, challengeId, stepKey, privateKey, keyId) {
  return jwt.sign(
    {
      sub: userId,
      jti: uuidv4(),
      challenge_id: challengeId,
      key: stepKey,
      status: "completed"
    },
    privateKey,
    {
      algorithm: "RS256",
      expiresIn: 300,
      notBefore: 0,
      keyid: keyId
    }
  );
}
```

### 2. Advance the challenge

Once your backend issues the verification token, the frontend SDK advances the challenge:

```javascript theme={null}
await client.continueStepUp(verificationToken, (info) => {
  console.log(info.currentStep); // next step key, or "completed"
});
```

See the [Web SDK Step-Up guide](/session/documentation/frontend-sdks/web/step-up#complete-a-custom-step) for full details.

Prelude validates the token by:

1. Fetching your public keys from your JWKS endpoint
2. Verifying the RS256 signature and expiration
3. Checking that `sub`, `challenge_id`, and `key` match the challenge token
4. Checking the `jti` has not been used before

If all steps are now done, the SDK automatically refreshes the session with the granted scope.

## Validation errors

| Error                        | Status | Description                                                                             |
| ---------------------------- | ------ | --------------------------------------------------------------------------------------- |
| `invalid_verification_token` | 400    | The verification token is malformed, expired, or the signature is invalid.              |
| `token_mismatch`             | 400    | `sub`, `challenge_id`, or `key` in the verification token does not match the challenge. |
| `step_not_completed`         | 400    | The step's status is not `"completed"`.                                                 |
| `step_bypassed`              | 400    | A previous step in the sequence has not been completed.                                 |
| `step_not_found`             | 404    | The step key from the verification token does not exist in the challenge.               |
| `token_reused`               | 409    | The verification token's `jti` has already been used.                                   |
