Answer

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 (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.

// 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.