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

Prelude Auth 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).           |
| `has_passkey`        | `bool`, `int`, `string`  | Whether the user has at least one passkey credential registered.       |

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