# Custom-token identity (https://docs.open-e2ee.dev/relay/custom-token.md)



Reviewed: 2026-10-19.

Use this path when your backend authenticates application members. Your backend signs an assertion for the member from its verified server session. Relay verifies that assertion before it registers a device.

## Configure an unused environment [#configure-an-unused-environment]

1. Open your Relay project settings.
2. Find **Identity in Production**.
3. Select &#x2A;*Custom token from your backend (ES256)**.
4. Enter the issuer, audience, subject claim, and public signing keys.
5. Validate a fresh registration assertion for a test member.
6. Save the configuration.
7. Reload identity status to confirm the saved profile.

Initial selection requires an environment without account history. An in-use profile requires a migration. A missing response does not prove failure. Retry the same operation or reload its status. Cancel only before its configuration commits.

Keep private signing keys on your backend. The Console accepts only public JWKs. A public key has this shape:

```json
{
  "kid": "inties-2026-10",
  "kty": "EC",
  "crv": "P-256",
  "x": "<base64url P-256 x coordinate>",
  "y": "<base64url P-256 y coordinate>"
}
```

Paste a JSON array of these keys into the form. Each coordinate encodes 32 bytes without padding. Each `kid` must be unique. The form accepts at most 32 keys.

## Select stable identity values [#select-stable-identity-values]

| Field         | Contract                                                                                                  |
| ------------- | --------------------------------------------------------------------------------------------------------- |
| Issuer        | An exact HTTPS `iss` string that your backend signs. Keep it stable.                                      |
| Audience      | One exact `aud` string for this environment. Use a different value for Sandbox. Arrays are invalid.       |
| Subject claim | Use `sub` for an immutable, non-reassigned member ID. A different claim requires the same stable mapping. |
| Signing key   | A public P-256 JWK. Sign with ES256 and its configured `kid`.                                             |

Derive the member ID from the authenticated server session. Do not trust a member ID supplied by the client. Email addresses and display names can change. A matching email does not authorize account migration.

An issuer or subject change can select a different Relay account. Signing-key rotation must preserve the issuer, mode, audience, and subject mapping.

## Assertion contract [#assertion-contract]

Return a compact signed JWT. Its protected header contains `alg: "ES256"`, `typ: "JWT"`, and a configured `kid`. External assertions cannot embed a key or supply `jku`, `x5c`, `x5u`, or `crit`.

| Claim               | Required value                                                                              |
| ------------------- | ------------------------------------------------------------------------------------------- |
| `iss`               | The configured issuer.                                                                      |
| `aud`               | The configured audience string.                                                             |
| `sub`               | The authenticated member ID, when the configured subject claim is `sub`.                    |
| `iat`, `nbf`, `exp` | Integer Unix seconds. `exp` must exceed `iat` by at most 600 seconds.                       |
| `jti`               | A nonempty assertion ID. Generate a fresh ID for each new assertion.                        |
| `token_use`         | `"identity"`.                                                                               |
| `purpose`           | The requested operation: `"register"`, `"refresh"`, or `"recover"`.                         |
| `assurance`         | `"normal"`, `"recent"`, or `"step-up"`, supported by actual server authentication evidence. |

Relay permits 30 seconds of clock tolerance. Keep the backend clock synchronized. The encoded assertion must not exceed 65,536 bytes.

The sample below signs five-minute assertions. It uses `jose` on a Node.js backend. The `session` argument must come from your application's server authentication. The client must never supply it.

```js title="relay-assertion-issuer.mjs"
import { randomUUID } from 'node:crypto';
import { importJWK, SignJWT } from 'jose';

export async function createRelayAssertionIssuer({
  privateJwk, kid, issuer, audience, publishableKey,
  nowSeconds = () => Math.floor(Date.now() / 1000),
}) {
  const privateKey = await importJWK(privateJwk, 'ES256');
  const rank = { normal: 0, recent: 1, 'step-up': 2 };

  return async function issue(session, request) {
    if (session?.active !== true || typeof session.memberId !== 'string' || !session.memberId) {
      throw new Error('An active authenticated member is required.');
    }
    if (!request || request.environment !== 'production' ||
        request.publishableKey !== publishableKey ||
        !['register', 'refresh', 'recover'].includes(request.purpose)) {
      throw new Error('The Relay assertion request is invalid.');
    }
    const required = request.assurance ?? 'normal';
    if (typeof required !== 'string' || !Object.hasOwn(rank, required)) {
      throw new Error('Invalid assurance.');
    }
    const now = nowSeconds();
    const recent = (time) => Number.isSafeInteger(time) &&
      time <= now && now - time <= 300;
    const available = recent(session.secondFactorAtSeconds) ? 'step-up'
      : recent(session.authenticatedAtSeconds) ? 'recent' : 'normal';
    if (rank[available] < rank[required]) {
      throw new Error('Reauthentication is required.');
    }
    return new SignJWT({
      token_use: 'identity', purpose: request.purpose, assurance: required,
    })
      .setProtectedHeader({ alg: 'ES256', typ: 'JWT', kid })
      .setIssuer(issuer)
      .setAudience(audience)
      .setSubject(session.memberId)
      .setIssuedAt(now)
      .setNotBefore(now)
      .setExpirationTime(now + 300)
      .setJti(randomUUID())
      .sign(privateKey);
  };
}
```

Use the actual sign-in and second-factor times from server authentication. Refreshing an application token does not establish a recent sign-in. Do not copy requested assurance into a claim without checking that evidence.

Create an authenticated application endpoint, such as `/api/relay-identity`, that calls this function. It must check the server session and current member status on every request. Protect cookie-authenticated requests against cross-site request forgery. Return `{ "assertion": "<signed JWT>" }` with `Cache-Control: no-store`. Do not log assertions or private keys.

## Connect the SDK callback [#connect-the-sdk-callback]

The SDK calls `getIdentityAssertion(request)` with the operation purpose, requested assurance, environment, and publishable key. This callback calls your authenticated application endpoint:

```ts
async function getIdentityAssertion(request) {
  const response = await fetch('/api/relay-identity', {
    method: 'POST',
    credentials: 'same-origin',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify(request),
    cache: 'no-store',
  });
  if (!response.ok) throw new Error('Application authentication is required.');
  const answer = await response.json();
  if (typeof answer.assertion !== 'string') throw new Error('Identity assertion missing.');
  return answer.assertion;
}
```

Pass this callback as `hosted.getIdentityAssertion` to `createHostedSignalProtocolClient()`. Use the Production connection URL and persistent device storage. Do not use `hostedRelaySandboxIdentity()` with that connection.

## Verify the member binding [#verify-the-member-binding]

Test registration for two different members. Confirm that they receive different Relay accounts. Confirm that repeat authentication for the same member preserves its Relay account. Reject a client-supplied ID for another member at your application endpoint.

Test disabled members, expired sessions, wrong issuer, wrong audience, unknown `kid`, changed subjects, and insufficient recovery assurance. The Console test checks one assertion against a draft. It does not test your endpoint's authorization rules or register a device.

Complete an encrypted exchange between test devices before member-device rollout. Keep Sandbox and Production storage separate.

## Key rotation and revocation limits [#key-rotation-and-revocation-limits]

The verifier uses configured public keys. It does not fetch a JWKS URL. Initial setup cannot replace an in-use key set through the identity form. Do not change identity values or delete accounts to rotate keys.

For a rotation procedure, require overlap before your backend uses a new key. Retain the old verification key until outstanding assertions expire, including clock tolerance. Test the removal boundary before member rollout.

Signing-key removal and assertion expiry do not revoke registered Relay devices. Those devices can renew Relay credentials with a device proof. Application logout does not revoke them either.

The current form does not suspend application members. Account recovery revokes earlier devices but replaces encryption identity and starts an empty mailbox generation. Do not use recovery as an account-disable operation.

Anonymous sending capabilities can remain usable under their own lifetime and recipient or group policy. They do not carry a sender member identity for targeted cancellation. Relay cannot recall content already downloaded or decrypted.

If your launch requires member suspension or in-use key rotation, keep registration paused until those operations and their exact limits pass your acceptance checks.
