Context
The suite serves two populations that must never mix: users who run a tenant, and members who are that tenant’s customers. They differ in scale, in trust and in blast radius — a member token that could reach a console permission would be a full compromise. ADR-010 keeps Softcrum as the identity authority while letting embedded integrations keep their own login experience. Native entry is OTP or magic link; delegated entry is token exchange, where the tenant’s backend signs a short-lived assertion and receives a scoped member token. It is the pattern Smile.io uses, and it is what makes headless mode a first-class citizen rather than a downgrade. Every authenticated member maps 1:1 to acore.contacts row. There is no member without a contact,
because the contact is the thing the rest of the platform is about.
And the identity is scoped to one tenant, always. Doña María shopping at company A and
subscribing to company B holds two member accounts with the same email address and no relationship
between them: two credentials, two sessions, two point balances, two consent records. Neither
tenant can discover the other. That is not an inconvenience to be optimised away later — it is the
isolation the whole multi-tenant model rests on (standards/data.md §2b).
Scope (normative)
- A separate Better Auth realm for members, with its own tables and configuration.
- Native entry: OTP and magic link over email, via Resend.
- Delegated entry:
POST /v1/core/auth/token-exchange. - Per-tenant signing keys with rotation.
- Scoped member tokens:
member:read,member:redeem,member:referral. - Single-use assertions with
jtireplay checking in Redis. - The contact ↔ member mapping.
Non-scope (normative)
- Console user authentication — the organizational realm, which predates this module.
- Social login for members. Deferred: it adds provider surface for a population that mostly arrives by link.
- Member password login. OTP and magic link only, deliberately: no member password means no member password to leak.
Behaviour (normative)
- Members live in a separate realm with no session, cookie or token overlap with the organizational realm. This is realm isolation, not a permission check — the separation is structural, so there is no privilege-escalation path to get wrong. 1b. Member identity is unique per tenant, never globally. The same email address may authenticate as a different member for every tenant that has it as a contact. An OTP issued for tenant A never authenticates against tenant B, and a member session always carries its tenant. FORBIDDEN: any index, lookup or account-linking flow that spans tenants.
- A member token can never carry a console permission. Enforced by construction, and by a test that asserts it.
- A token-exchange assertion is a JWT signed with a per-tenant key, valid for ≤5 minutes,
single-use via a
jtireplay check in Redis. Short life plus single use means a captured assertion is worth almost nothing. - The assertion carries the tenant’s
external_idfor the member. Resolution finds or creates the matching contact — a member arriving for the first time is not an error. - Every authenticated member maps 1:1 to a contact. A member without a contact is an invalid state.
- Signing keys rotate without invalidating live sessions: an overlap window accepts both keys. A rotation that logs everyone out is a rotation nobody performs.
- Member tokens are scoped. A
member:readtoken cannot redeem, and the scope is checked at the endpoint, not in the client. - OTP codes are single-use, expire in 10 minutes, and are rate-limited per contact and per IP.
- FORBIDDEN: logging a token or an assertion · accepting an assertion signed with another tenant’s key · issuing a member token whose contact belongs to a different tenant.
Data (normative)
Member credentials live in the member realm’s own tables, and every uniqueness constraint on them
is
(tenant_id, …) per standards/data.md §2b. There is no global member account.
Better Auth owns the realm’s own tables; this one records what we need for revocation and audit.
API (normative)
Events (normative)
None on the outbox. Authentication is recorded incore.audit_log with a member actor; emitting
a domain event per login would flood every consumer for no benefit.
Acceptance criteria (normative)
- A member token is rejected by every console endpoint — asserted across the full endpoint set, not a sample. 1b. The same email registered as a member of two tenants produces two independent accounts: an OTP issued for tenant A fails against tenant B, and neither account’s existence is discoverable from the other.
- A replayed assertion (same
jti) is rejected on the second use. - An assertion older than 5 minutes is rejected.
- An assertion signed with tenant A’s key cannot mint a token for tenant B’s member.
- Key rotation keeps existing sessions valid throughout the overlap window.
- A first-time
external_idcreates the contact and returns a token in one call. - OTP codes are single-use and expire; a second verification of the same code fails.
- Negative: no log line contains a token, an assertion or an OTP code.
Execution
Synchronous command. Realm configuration and endpoints inbackend/api; the replay check uses the
same Upstash primitives as rate limiting.