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

> Configure the claims included in the access tokens issued by the Session API.

The Session API lets you control which claims are included in the access tokens issued to your users. With a single configuration per application, you decide what data ends up in the JWT — pulled from the session, the user's profile, or hardcoded values you define.

## Why custom claims?

By default, an access token issued by Prelude only contains the standard JWT claims (`iss`, `sub`, `exp`, ...) and the scopes granted to the session. If your backend needs more context — the user's locale, IP address, your own internal user ID, a loyalty tier — you can declare it once in a **claims mapping configuration** and Prelude will inject it into every access token issued for that application.

You can mix three kinds of values in the same configuration:

* **Hardcoded values** — constants that always appear in the token (e.g. an API version number).
* **Built-in inputs** — data Prelude already knows about the user or the session (user ID, IP, locales, emails, ...).
* **Profile custom claims** — arbitrary data you store on the user's profile via the [Management API](/session/api-reference/management/users/update-user-profile).

## How it works

```mermaid theme={null}
sequenceDiagram
  autonumber
  participant Y as Your Backend
  participant P as Prelude API
  actor U as User

  Y->>P: POST /v2/session/apps/{appID}/config/claims (mapping)
  P-->>Y: 201 Created

  U->>P: Login (OTP, password, social, ...)
  P->>P: Resolve mapping against profile + session
  P-->>U: access_token (JWT with custom claims)
```

You configure the mapping **once per application** through the Management API. From that moment on, every access token Prelude issues for that application — at login, refresh, or step-up — embeds the resolved claims.

<Note>
  When you update the claims mapping, existing sessions automatically pick up the new configuration on their next refresh. There is nothing to do on the client side.
</Note>

## Mapping format

A claims mapping is a free-form JSON object passed under the `mapping` key. Each top-level key becomes a claim in the access token. Nested objects are preserved as-is.

```json theme={null}
{
  "mapping": {
    "api_version": 2,
    "user_id": {
      "$input": "user_id",
      "$type": "uuid"
    },
    "loyalty_tier": {
      "$custom_claim": "loyalty_tier"
    },
    "context": {
      "ip": {
        "$input": "ip",
        "$type": "string"
      },
      "country": {
        "$input": "country_code",
        "$type": "string"
      }
    }
  }
}
```

This produces an access token whose payload includes:

```json theme={null}
{
  "iss": "https://<app_id>.session.prelude.dev",
  "sub": "...",
  "exp": 1713361200,
  "iat": 1713357600,
  "jti": "...",
  "sid": "ses_01kh1g2hp6ed7ar3ees1vxnkn7",
  "scope": "openid profile",

  "api_version": 2,
  "user_id": "019bd5d7-f977-76a5-a1ad-37260c9a7a3f",
  "loyalty_tier": "gold",
  "context": {
    "ip": "194.250.248.220",
    "country": "FR"
  }
}
```

### Hardcoded values

Any non-object value (string, number, boolean) is copied verbatim into the token:

```json theme={null}
{
  "api_version": 2,
  "tenant": "production",
  "feature_flag_enabled": true
}
```

### Built-in inputs

Use the `$input` / `$type` operators to reference data that Prelude already knows about the user and the session. Both operators are required together.

```json theme={null}
{
  "user_locales": {
    "$input": "locales",
    "$type": "string-array"
  }
}
```

The full list of supported inputs is in [Available inputs](#available-inputs) below.

### Profile custom claims

Any field stored on the user's profile via [`PATCH /v2/session/apps/{appID}/users/{userID}/profile`](/session/api-reference/management/users/update-user-profile) can be referenced with the `$custom_claim` operator:

```json theme={null}
{
  "loyalty_tier": {
    "$custom_claim": "loyalty_tier"
  }
}
```

If the referenced field is not present on the user's profile, the claim is **omitted** from the token (rather than being set to `null`).

<Note>
  Profile claims are resolved at every token issuance, so updates to a user's profile are reflected in the next access token without requiring re-authentication.
</Note>

### Nested claims

Mapping objects can be nested arbitrarily. Anything that is not a `$input`/`$type` or `$custom_claim` template is treated as a plain nested object:

```json theme={null}
{
  "context": {
    "device": {
      "ip": { "$input": "ip", "$type": "string" }
    }
  }
}
```

## Available inputs

The following template names can be used with `$input`. Each input has a fixed set of supported `$type` values.

| Input                | Supported types          | Description                                                            |
| -------------------- | ------------------------ | ---------------------------------------------------------------------- |
| `user_id`            | `uuid`, `string`         | The Prelude user ID.                                                   |
| `session_id`         | `uuid`, `string`         | The current session ID.                                                |
| `external_id`        | `string`                 | The user's external ID, set via the Management API.                    |
| `is_first_session`   | `bool`, `int`, `string`  | Whether this is the user's first session.                              |
| `ip`                 | `string`                 | The IP address of the client when the session was created.             |
| `country_code`       | `string`                 | ISO 3166-1 alpha-2 country code derived from the IP address.           |
| `preferred_language` | `string`                 | The user's preferred language, taken from the profile.                 |
| `locales`            | `string-array`, `string` | The list of locales known for the user (from social login or profile). |
| `given_name`         | `string`                 | The user's given name (from social login or profile).                  |
| `family_name`        | `string`                 | The user's family name (from social login or profile).                 |
| `picture`            | `string`                 | URL of the user's profile picture (from social login or profile).      |
| `emails`             | `string-array`, `string` | All verified email identifiers attached to the user.                   |
| `phone_numbers`      | `string-array`, `string` | All verified phone identifiers attached to the user (E.164).           |

### Type conversions

| Type           | Behavior                                                                                                                           |
| -------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| `string`       | Renders the value as a string. Booleans become `"true"`/`"false"`, numbers are stringified, arrays are joined with a single space. |
| `uuid`         | Returns the canonical UUID string representation of an ID. Only valid for `user_id` and `session_id`.                              |
| `bool`         | Coerces the value to a boolean. Strings `"true"`/`"false"` and non-zero numbers are accepted.                                      |
| `int`          | Coerces the value to an integer.                                                                                                   |
| `string-array` | Returns the value as a JSON array of strings. A scalar input is wrapped in a single-element array.                                 |

If an input has no value at token issuance time (for example the user has no email identifier yet), the claim is **omitted** from the token.

## Reserved claims

The following claims are reserved by the JWT specification and **cannot be used as top-level keys** in your mapping. They are managed by Prelude and would be stripped or rejected:

`iss` · `sub` · `aud` · `exp` · `nbf` · `iat` · `jti` · `sid` · `scope`

Attempting to set any of them at the root level returns `400 invalid_claim_override`. They can still appear as keys inside nested objects (e.g. `"metadata": { "iss": "..." }`).

## Managing the configuration

Each application can have **at most one** claims mapping configuration. The Management API exposes the standard CRUD operations.

<Steps>
  <Step title="Create the configuration">
    ```bash theme={null}
    curl -X POST https://api.prelude.dev/v2/session/apps/${APP_ID}/config/claims \
      -H "Authorization: Bearer ${MANAGEMENT_API_KEY}" \
      -H "Content-Type: application/json" \
      -d '{
        "mapping": {
          "user_id": { "$input": "user_id", "$type": "uuid" },
          "country": { "$input": "country_code", "$type": "string" },
          "loyalty_tier": { "$custom_claim": "loyalty_tier" }
        }
      }'
    ```

    Returns `201 Created` with the saved configuration. If a configuration already exists, the request fails with `409 claims_mapping_config_already_exists` — use `PUT` instead.
  </Step>

  <Step title="Update the configuration">
    ```bash theme={null}
    curl -X PUT https://api.prelude.dev/v2/session/apps/${APP_ID}/config/claims \
      -H "Authorization: Bearer ${MANAGEMENT_API_KEY}" \
      -H "Content-Type: application/json" \
      -d '{
        "mapping": { ... }
      }'
    ```

    Replaces the existing configuration entirely. Existing sessions pick up the new mapping on their next refresh.
  </Step>

  <Step title="Read the configuration">
    ```bash theme={null}
    curl https://api.prelude.dev/v2/session/apps/${APP_ID}/config/claims \
      -H "Authorization: Bearer ${MANAGEMENT_API_KEY}"
    ```

    Returns `{ "config": null }` when no configuration exists.
  </Step>

  <Step title="Delete the configuration">
    ```bash theme={null}
    curl -X DELETE https://api.prelude.dev/v2/session/apps/${APP_ID}/config/claims \
      -H "Authorization: Bearer ${MANAGEMENT_API_KEY}"
    ```

    Returns `204 No Content`. Future access tokens will only contain the standard JWT claims.
  </Step>
</Steps>

## Errors

| HTTP status | Code                                   | When it happens                                                                                                                                              |
| ----------- | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| 400         | `invalid_request`                      | The request body is malformed, or a template object mixes incompatible operators (e.g. `$input` without `$type`, or `$custom_claim` together with `$input`). |
| 400         | `invalid_template_type`                | The `$input` value is not a known template name, or the `$type` is not supported for that input (e.g. `int` for `emails`).                                   |
| 400         | `invalid_claim_override`               | A reserved JWT claim (`iss`, `sub`, `aud`, `exp`, `nbf`, `iat`, `jti`, `sid`, `scope`) appears at the root of the mapping.                                   |
| 409         | `claims_mapping_config_already_exists` | `POST` was called while a configuration already exists. Use `PUT` to update it.                                                                              |

## Validation rules

When you submit a mapping, Prelude validates the structure before storing it:

* A template object using `$input` must also include `$type`, and only those two keys.
* A template object using `$custom_claim` must contain only that key.
* `$input`, `$type`, and `$custom_claim` values must be strings.
* The `$input` value must match one of the names in [Available inputs](#available-inputs).
* The `$type` value must be one of the types listed for that input.
* Reserved JWT claims cannot appear at the root level.

Hardcoded scalar values (strings, numbers, booleans) are not validated for shape — anything JSON-serializable is accepted.

## Verifying tokens

Custom claims are signed alongside the standard JWT claims, so verifying a token has not changed: fetch the public keys from your application's [JWKS endpoint](/session/documentation/jwks) and verify the signature as usual. Once the signature is valid, the custom claims you configured can be read straight from the JWT payload.

## What's next?

<CardGroup cols={2}>
  <Card title="JWKS" icon="key-skeleton-left-right" href="/session/documentation/jwks">
    Verify the signature of access tokens issued by the Session API.
  </Card>

  <Card title="Claims Configuration API" icon="code" href="/session/api-reference/management/config/claims/create-claims-mapping">
    Full API reference for managing the claims mapping configuration.
  </Card>
</CardGroup>
