OpenE2EE

Custom-token identity

Bind Relay accounts to application members with signed ES256 assertions.

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

  1. Open your Relay project settings.
  2. Find Identity in Production.
  3. Select 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:

{
  "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

FieldContract
IssuerAn exact HTTPS iss string that your backend signs. Keep it stable.
AudienceOne exact aud string for this environment. Use a different value for Sandbox. Arrays are invalid.
Subject claimUse sub for an immutable, non-reassigned member ID. A different claim requires the same stable mapping.
Signing keyA 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

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.

ClaimRequired value
issThe configured issuer.
audThe configured audience string.
subThe authenticated member ID, when the configured subject claim is sub.
iat, nbf, expInteger Unix seconds. exp must exceed iat by at most 600 seconds.
jtiA nonempty assertion ID. Generate a fresh ID for each new assertion.
token_use"identity".
purposeThe 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.

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

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

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

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

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.

On this page