EmofyEmofy Developers
Auth & Tenancy

Multi-Tenancy

How organizations model tenancy, how org context is resolved on every request, and the super-admin act-as-org path.

Emofy is multi-tenant: an organization is a tenant (a daycare, a school, a group). Almost all data is org-scoped, and a single user can belong to several orgs with different roles in each.

The model

Tenancy is provided by Better Auth's organization plugin, stored in the auth database:

  • organization — the tenant.
  • member — a (user, organization, role) link. The role column is a comma-separated string; the first entry is the primary role, the rest are additional roles.

A user's membership is resolved with a single join:

SELECT member.role, organization.slug
FROM member
JOIN organization ON organization.id = member.organizationId
WHERE member.userId = ? AND member.organizationId = ?;

Those values are baked into the JWT (orgId, orgRole, additionalRoles, orgSlug) at sign-in, so the request hot path never needs an auth-DB lookup to know who the caller is and what org they're acting in.

How org context is resolved per request

After the JWT is verified, OrgScopeInterceptor establishes the request's org context. The rules differ by caller type — and this distinction is a security boundary:

Regular users

request.orgId   = user.orgId        (from the JWT — never the header)
request.orgRole = user.orgRole

A regular user's org comes only from their token. Even if the client sends an X-Org-Id header, it is discarded — this prevents header-based tenant spoofing.

Super-admins (the admin app)

request.orgId   = user.orgId  ?? header["X-Org-Id"]
request.orgRole = user.orgRole ?? header["X-Org-Role"]

A super-admin JWT carries no org claims. The admin app supplies the target org through X-Org-Id / X-Org-Role headers — the "act-as-org" path. The interceptor honors these headers only for super-admins.

This is why the admin API client and the consumer API client must never be merged: the admin client deliberately sends X-Org-Id, which would be a spoofing vector if a non-super-admin client did the same.

Enforcement: scoping is automatic

Carrying the org context is only half the story; the other half is making sure every query respects it. That happens in the data layer:

  • OrgGuard rejects an authenticated non-super-admin with no org context.
  • MemberStatusGuard ensures the caller is still an active member (300s cache), so a removed member loses access quickly.
  • OrgScopedRepository automatically applies orgId to every read and write. Raw queries on tenant tables are forbidden — see Data model.

The result: tenant isolation is a property of the framework, not of developer discipline.

Switching organizations

Because membership is many-to-many, the account app offers org switching: the session's active org is changed, a new JWT is minted with the new org claims, and the consumer app re-scopes (its routes are shaped /w/{orgId}/*). On the frontend, an OrgProvider exposes orgId, orgSlug, orgRole, additionalRoles, and switchOrg().

Partner API tenancy

Partner API credentials are scoped to one organization and one environment, so a partner integration authenticates as a whole org rather than a user. See Developer authentication and the environment model.

Next

On this page