# Better Auth backend recipe (https://docs.open-e2ee.dev/relay/better-auth.md)



Reviewed: 2026-10-22.

Use this recipe for application-bound Production accounts. Your backend verifies the Better Auth session and current application membership. It then signs the existing [ES256 assertion](https://docs.open-e2ee.dev/relay/custom-token.md). Better Auth tokens are not a native Relay identity profile.

## Prerequisites [#prerequisites]

* A stateful Better Auth session store that supports authoritative session revocation.
* A configured Production custom-token environment and backend-only signing key.
* An immutable member mapping in a server-selected tenant.
* A same-origin application endpoint protected against cross-site request forgery.

The local recipe tests use Better Auth 1.7.12 and its official memory adapter. The memory adapter is for tests. Configure a persistent supported database for your application.

## Read the server session [#read-the-server-session]

Better Auth's primary session cookie contains an opaque session token. Its optional session-data cookie caches session data. A cached value can survive server-side revocation until the cache expires. The broker below calls the actual server `auth.api.getSession` with `disableCookieCache: true` on every request. Do not reuse a session cached by earlier middleware.

The separate JWT plugin issues external JWTs. Its defaults are EdDSA with Ed25519 keys, a 15-minute lifetime, and no automatic key rotation. Those defaults do not meet the Relay ES256 assertion contract. JWT-plugin output is not this broker's output.

Copy the issuer from [Custom-token identity](https://docs.open-e2ee.dev/relay/custom-token.md#assertion-contract) into `relay-assertion-issuer.mjs`. Keep its private key on the backend. Then use this handler:

```js title="better-auth-relay-broker.mjs"
export function createBetterAuthRelayBroker({
  auth, issue, origin, tenantId, findMember,
}) {
  const reply = (status, body) => Response.json(body, {
    status, headers: { 'cache-control': 'no-store' },
  });
  return async function handle(request) {
    if (request.method !== 'POST') return reply(405, { error: 'method' });
    if (new URL(request.url).origin !== origin ||
        request.headers.get('origin') !== origin) {
      return reply(403, { error: 'origin' });
    }
    if (request.headers.get('content-type') !== 'application/json') {
      return reply(400, { error: 'request' });
    }
    try {
      const reader = request.body?.getReader();
      if (!reader) return reply(400, { error: 'request' });
      const chunks = [];
      let size = 0;
      try {
        while (true) {
          const { done, value } = await reader.read();
          if (done) break;
          size += value.byteLength;
          if (size > 4096) {
            await reader.cancel();
            return reply(400, { error: 'request' });
          }
          chunks.push(value);
        }
      } finally { reader.releaseLock(); }
      const bytes = new Uint8Array(size);
      let offset = 0;
      for (const chunk of chunks) {
        bytes.set(chunk, offset);
        offset += chunk.byteLength;
      }
      const input = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes));
      if (!input || Array.isArray(input) || typeof input !== 'object' ||
          Object.keys(input).some((key) =>
            !['purpose', 'assurance', 'environment', 'publishableKey'].includes(key))) {
        return reply(400, { error: 'request' });
      }
      const session = await auth.api.getSession({
        headers: request.headers,
        query: { disableCookieCache: true, disableRefresh: true },
      });
      if (!session || typeof session.user.id !== 'string') {
        return reply(401, { error: 'authentication' });
      }
      const member = await findMember({ tenantId, userId: session.user.id });
      if (!member || member.tenantId !== tenantId || member.active !== true ||
          typeof member.memberId !== 'string' || !member.memberId) {
        return reply(403, { error: 'membership' });
      }
      const subject = JSON.stringify([tenantId, member.memberId]);
      const assertion = await issue({ active: true, memberId: subject }, input);
      return reply(200, { assertion });
    } catch {
      return reply(401, { error: 'refused' });
    }
  };
}
```

Create the handler once with your actual `auth` instance, configured `issue` function, fixed application origin, and fixed tenant. `findMember` must read your authoritative application membership store. It must map the verified Better Auth user to a non-reassigned application member. It must not accept a tenant or member ID from the request body.

The signed subject is `JSON.stringify([tenantId, member.memberId])`. Preserve both strings and this encoding. Equal local member IDs in different tenants must select different accounts. JSON array encoding preserves separators and escaping without delimiter collisions. Changing this mapping for an in-use profile requires the supported migration procedure.

Mount the handler on your application endpoint. The [SDK callback](https://docs.open-e2ee.dev/relay/custom-token.md#connect-the-sdk-callback) posts its request to that endpoint. The handler bounds the body, requires an exact origin, and returns no-store responses. Configure your framework's CSRF controls too. Do not log session cookies, assertions, or signing keys.

This example covers the current profile. It refuses additional fields, including identity migration context. Use the separately supported migration procedure for an in-use profile. Do not turn a client-selected provider role into authority.

## Keep assurance separate [#keep-assurance-separate]

This handler issues only normal assurance. It does not copy Better Auth session timestamps into authentication evidence. Recent or step-up requests refuse until your backend supplies independently verified reauthentication or second-factor receipts through a reviewed integration.

Better Auth's `freshAge` check uses session creation time. Session refresh, `updatedAt`, JWT issuance, and a freshness default do not prove a new sign-in or second factor. A valid recovery assertion also requires Relay's configured recovery policy.

## Verify the boundary [#verify-the-boundary]

Test a real sign-in, missing and expired sessions, server-side revocation with a retained cache cookie, and current membership loss. Test a foreign tenant and client-supplied subject. Phone or username changes must preserve the same member ID.

The local provider tests exercise the actual Better Auth API. The explicit recipe verifier also checks generated assertions with the actual pinned Relay verifier. That evidence covers assertion issuance and verification. It does not prove hosted deployment, Account admission, MFA, or customer suspension.

## Existing devices and lifecycle [#existing-devices-and-lifecycle]

Refusing new assertions does not revoke registered devices. Devices can renew Relay credentials with device proofs. Anonymous sending capabilities retain their own recipient, group, and lifetime limits.

Customer suspension/resume instructions await the supported backend and CLI surface. This page specifies no customer endpoint or CLI command before that surface exists. Do not call private Relay controls or distribute internal service credentials. See [backend identity contracts](https://docs.open-e2ee.dev/relay/backend-identity.md).

## Primary references [#primary-references]

* [Better Auth server usage](https://better-auth.com/docs/basic-usage)
* [Session management and cookie cache](https://better-auth.com/docs/concepts/session-management)
* [JWT plugin](https://better-auth.com/docs/plugins/jwt)
