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. Therolecolumn 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.orgRoleA 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:
OrgGuardrejects an authenticated non-super-admin with no org context.MemberStatusGuardensures the caller is still an active member (300s cache), so a removed member loses access quickly.OrgScopedRepositoryautomatically appliesorgIdto 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.