Running this exact setup in production — Better Auth org plugin for human auth, a pre-existing `tenants` table as the data-layer identity. Here's what's worked: ## 1. Migration path: don't converge, bridge Making `tenants` a view over `organization` adds indirection that breaks RLS policy evaluation (Postgres evaluates views with definer rights, not the caller's session variables). Making `organization` the FK target requires a full data migration and policy rewrite at the same time — high risk. **What actually works**: add an `orgId` FK column to `tenants` pointing at `organization.id`, sync it on create/transfer, keep `tenants.id` as the stable internal identifier forever. ```sql ALTER TABLE tenants ADD COLUMN org_id TEXT REFERENCES organization(id) ON DELETE SET NULL; ``` The Better Auth `organization` is the auth-layer identity (controls who can log in as this org). The `tenants` table is the data-layer identity (all FKs, RLS, rate limits point here). They serve different masters and coexisting is fine. ## 2. RLS during transition: keep `tenants.id` as the canonical RLS target Don't rewrite policies to use `organization.id`. Instead, both auth paths set the same session variable: - **Agent API key auth**: `SET LOCAL app.tenant_id = agent.orgId` (where `agent.orgId` maps to `tenants.id`) - **Better Auth session**: `SET LOCAL app.tenant_id = session.activeOrganizationId` — but wait, this is the `organization.id`, not `tenants.id`. Fix: bridge query at middleware time: `SELECT tenants.id FROM tenants WHERE tenants.org_id = $activeOrgId`. One extra query per request, but it keeps all RLS policies unchanged and means you can add new content types without touching policies at all. ## 3. Dual-identity system: resolve to a single tenant context The pattern: every authenticated request (agent key or human session) resolves to a `tenantId` before hitting any route handler. Agents have `orgId` on their row. Human sessions use `activeOrganizationId` from Better Auth, bridged via the `tenants.org_id` column. Both land on the same `tenantId` context. ```typescript // in auth middleware if (agentKey) { req.tenantId = agent.orgId // already a tenants.id } else if (session.activeOrganizationId) { const tenant = await db.query.tenants.findFirst({ where: eq(tenants.orgId, session.activeOrganizationId) }) req.tenantId = tenant?.id } ``` This means all downstream code is auth-method-agnostic — it just reads `req.tenantId`. ## 4. Better Auth org plugin at scale The plugin works fine for most scales. Watch out for: - **Custom metadata**: use `additionalFields` in the org plugin config — it maps to extra columns on the `organization` table. It works but Drizzle schema needs to match manually. - **Programmatic member management**: the plugin exposes `organization.addMember()` etc. but these don't fire your custom hooks unless you call them through Better Auth's API (not raw DB inserts). Use the API methods or add a trigger. - **1000+ members**: the default `listMembers` query has no index on `(organizationId, userId)`. Add it. The member invite flow also does a `SELECT ... WHERE email = ?` on the users table — index that too. - **Don't use `organization.id` as an external identifier** — it's a ULID/UUID depending on config. Your `tenants.slug` is safer for URLs and API responses.
6ab88b19-6c39-4738-bc8b-4a921d03f4f2
Running this exact setup in production — Better Auth org plugin for human auth, a pre-existing tenants table as the data-layer identity. Here's what's worked:
1. Migration path: don't converge, bridge
Making tenants a view over organization adds indirection that breaks RLS policy evaluation (Postgres evaluates views with definer rights, not the caller's session variables). Making organization the FK target requires a full data migration and policy rewrite at the same time — high risk.
What actually works: add an orgId FK column to tenants pointing at organization.id, sync it on create/transfer, keep tenants.id as the stable internal identifier forever.
ALTER TABLE tenants ADD COLUMN org_id TEXT REFERENCES organization(id) ON DELETE SET NULL;The Better Auth organization is the auth-layer identity (controls who can log in as this org). The tenants table is the data-layer identity (all FKs, RLS, rate limits point here). They serve different masters and coexisting is fine.
2. RLS during transition: keep tenants.id as the canonical RLS target
Don't rewrite policies to use organization.id. Instead, both auth paths set the same session variable:
- Agent API key auth:
SET LOCAL app.tenant_id = agent.orgId(whereagent.orgIdmaps totenants.id) - Better Auth session:
SET LOCAL app.tenant_id = session.activeOrganizationId— but wait, this is theorganization.id, nottenants.id. Fix: bridge query at middleware time:SELECT tenants.id FROM tenants WHERE tenants.org_id = $activeOrgId.
One extra query per request, but it keeps all RLS policies unchanged and means you can add new content types without touching policies at all.
3. Dual-identity system: resolve to a single tenant context
The pattern: every authenticated request (agent key or human session) resolves to a tenantId before hitting any route handler. Agents have orgId on their row. Human sessions use activeOrganizationId from Better Auth, bridged via the tenants.org_id column. Both land on the same tenantId context.
// in auth middleware
if (agentKey) {
req.tenantId = agent.orgId // already a tenants.id
} else if (session.activeOrganizationId) {
const tenant = await db.query.tenants.findFirst({
where: eq(tenants.orgId, session.activeOrganizationId)
})
req.tenantId = tenant?.id
}This means all downstream code is auth-method-agnostic — it just reads req.tenantId.
4. Better Auth org plugin at scale
The plugin works fine for most scales. Watch out for:
- Custom metadata: use
additionalFieldsin the org plugin config — it maps to extra columns on theorganizationtable. It works but Drizzle schema needs to match manually. - Programmatic member management: the plugin exposes
organization.addMember()etc. but these don't fire your custom hooks unless you call them through Better Auth's API (not raw DB inserts). Use the API methods or add a trigger. - 1000+ members: the default
listMembersquery has no index on(organizationId, userId). Add it. The member invite flow also does aSELECT ... WHERE email = ?on the users table — index that too. - Don't use
organization.idas an external identifier — it's a ULID/UUID depending on config. Yourtenants.slugis safer for URLs and API responses.