Skip to content

Multi-tenancy fails in production when isolation is only a WHERE organization_id = ? habit. One missed filter, one confused deputy between workspaces, or one background job that forgets the tenant context is enough to leak data.

A production-grade service needs three layers that agree:

  1. Identity / membership — who the user is, which organizations they belong to, which org is active, and what role they hold (Better Auth organization plugin)
  2. Request context — every protected Hono handler receives a verified userId and organizationId (never from a client-controlled header alone)
  3. Data boundaryPostgreSQL row-level security denies cross-tenant rows even if application code forgets a filter

This note assumes a typed backend with Hono, Better Auth sessions, Drizzle, and PostgreSQL. That base stack is covered in Backend APIs with Hono, Drizzle, Zod OpenAPI, and SST. Here the focus is tenant isolation.



1. What Production Multi-Tenant Means Here

In this architecture, a tenant is an organization: a SaaS workspace with members, roles, and its own business data. Users can belong to many organizations. The session tracks which organization is active.

The approach uses a shared database and a shared schema. Every tenant-owned row carries an organization_id. That is the common SaaS model, and it fits Better Auth's organization plugin directly.

There are softer and harder forms of isolation:

  • Soft isolation — application code always filters by organizationId. Fast to start, easy to get wrong under pressure.
  • Database-enforced isolationPostgres RLS enforces the same rule under a trusted tenant-context path. A forgotten WHERE returns zero rows instead of another tenant's data.

Threats this design addresses:

  • Missing tenant filters in a new query or admin script
  • Resource IDs leaked in URLs (/projects/:id) that belong to another org
  • Accepting X-Organization-Id without verifying membership
  • Connection pooling reusing a session that still has another tenant's settings
  • Break-glass admin paths that quietly disable isolation in production

Non-goals for this note: schema-per-tenant, database-per-tenant, and billing or metering. Those are different architectures. Teams and dynamic access control exist in Better Auth; v1 stays on static roles.



2. The Architecture

The request path adds tenant resolution and an RLS-scoped transaction on top of normal session auth:


Client
  → session cookie
  → Hono (or Lambda authorizer + Hono)
  → Better Auth getSession
  → read organizationId from the route
  → verify membership/permission for that organizationId
  → open DB transaction
  → set_config('app.organization_id', ...)
  → Drizzle queries under RLS
  → PostgreSQL

Responsibilities stay narrow:

  • Better Auth organization manages orgs, members, invitations, activeOrganizationId, and roles or permissions.
  • Hono middleware attaches typed tenant context and rejects requests without membership in the explicit route organization.
  • Drizzle schema puts organization_id and RLS policies on every tenant-owned table.
  • PostgreSQL is the last line of defense.

If the API Gateway Lambda authorizer pattern from the base note is in use, pass only small identity identifiers such as userId and sessionId. The business route still supplies organizationId, and the API verifies membership for that exact organization before opening the RLS transaction. Never copy identity or tenant from a client-controlled header without that verification.



3. Better Auth Organization as the Tenancy Control Plane

The organization plugin is the membership and workspace layer. Install @better-auth/drizzle-adapter, enable the plugin on the server and client, then generate and apply a migration so organization, member, invitation, and session.activeOrganizationId exist in PostgreSQL.


import { drizzleAdapter } from "@better-auth/drizzle-adapter"
import { betterAuth } from "better-auth"
import { organization } from "better-auth/plugins"
import { ac, owner, admin, member } from "./permissions"
import { db } from "./db"
import { sendOrganizationInvitation } from "./email"

const appURL = process.env.APP_URL
if (!appURL) throw new Error("APP_URL is required")

export const auth = betterAuth({
  database: drizzleAdapter(db, { provider: "pg" }),
  plugins: [
    organization({
      ac,
      roles: { owner, admin, member },
      requireEmailVerificationOnInvitation: true,
      disableOrganizationDeletion: true,
      async sendInvitationEmail(data) {
        const inviteLink = `${appURL}/accept-invitation/${data.id}`

        await sendOrganizationInvitation({
          to: data.email,
          organization: data.organization.name,
          inviter: data.inviter.user.name,
          inviteLink,
        })
      },
    }),
  ],
})

import { createAuthClient } from "better-auth/client"
import { organizationClient } from "better-auth/client/plugins"
import { ac, owner, admin, member } from "./permissions"

export const authClient = createAuthClient({
  plugins: [
    organizationClient({
      ac,
      roles: { owner, admin, member },
    }),
  ],
})

The invitation callback must deliver the opaque invitation ID only to the intended recipient. Acceptance requires an authenticated session whose email matches the invitation. Require email verification and disable direct organization deletion so deletion goes through an explicit archival workflow.

After adding or changing a plugin, generate the Better Auth Drizzle schema, generate a SQL migration, review it, then apply it:


npx auth@latest generate
npx drizzle-kit generate
npx drizzle-kit migrate

The first command writes schema definitions; it does not change PostgreSQL. In CI, pin the CLI to the Better Auth version used by the application instead of allowing @latest to move independently.

Lifecycle

The happy path is short:


create organization
  → invite member
  → accept invitation
  → set-active organization
  → work inside that tenant
  → leave or archive through an explicit workflow

Active organization is session state, but it should be treated as a UI preference rather than authorization input for business mutations. Clients call organization.setActive; the server stores activeOrganizationId on the session. Business routes still carry an explicit organization ID and verify membership for that exact ID.

To seed an active org when a session is created, add a database hook to the same auth configuration:


export const auth = betterAuth({
  databaseHooks: {
    session: {
      create: {
        before: async (session) => {
          const organization = await getInitialOrganization(session.userId)
          return {
            data: {
              ...session,
              activeOrganizationId: organization?.id,
            },
          }
        },
      },
    },
  },
  plugins: [organization({ ac, roles: { owner, admin, member } })],
})

Roles and permissions

Default roles are owner, admin, and member. For domain actions, define an access controller and pass it into the plugin:


import { createAccessControl } from "better-auth/plugins/access"
import {
  defaultStatements,
  adminAc,
  memberAc,
  ownerAc,
} from "better-auth/plugins/organization/access"

const statement = {
  ...defaultStatements,
  project: ["create", "read", "update", "delete"],
} as const

export const ac = createAccessControl(statement)

export const member = ac.newRole({
  project: ["create", "read"],
  ...memberAc.statements,
})

export const admin = ac.newRole({
  project: ["create", "read", "update", "delete"],
  ...adminAc.statements,
})

export const owner = ac.newRole({
  project: ["create", "read", "update", "delete"],
  ...ownerAc.statements,
})

Route handlers and middleware call auth.api.hasPermission with the request headers and the same explicit organization ID used by the route:


const result = await auth.api.hasPermission({
  headers: c.req.raw.headers,
  body: {
    organizationId,
    permissions: {
      project: ["create"],
    },
  },
})

if (!result.success) {
  return c.json(
    { code: "FORBIDDEN", message: "Missing project:create permission." },
    403
  )
}

Teams and dynamic access control are available when the product outgrows static roles. Leave them disabled on day one.



4. Domain Schema: Every Business Row Is Tenant-Owned

Auth tables stay global: user, account, session, verification, organization, member, invitation. Business tables are tenant-owned.

The rule is simple: if the row belongs to a workspace, it has a non-null organization_id foreign key to organization.id, tenant-scoped uniqueness where needed, and indexes designed around real tenant access paths.

Better Auth organization IDs are strings, so domain foreign keys should be text, not uuid, unless ID generation has been customized.


import { sql } from "drizzle-orm"
import {
  index,
  pgPolicy,
  pgRole,
  pgTable,
  text,
  timestamp,
  uniqueIndex,
  uuid,
} from "drizzle-orm/pg-core"
import { organization } from "./auth-schema"

// Provisioned by infrastructure as a restricted, non-owner login role.
export const appUser = pgRole("app_user").existing()

export const projects = pgTable(
  "projects",
  {
    id: uuid("id").defaultRandom().primaryKey(),
    organizationId: text("organization_id")
      .notNull()
      .references(() => organization.id, { onDelete: "restrict" }),
    name: text("name").notNull(),
    slug: text("slug").notNull(),
    createdAt: timestamp("created_at", { withTimezone: true })
      .defaultNow()
      .notNull(),
  },
  (table) => [
    index("projects_org_created_at_idx").on(
      table.organizationId,
      table.createdAt
    ),
    uniqueIndex("projects_org_slug_uidx").on(table.organizationId, table.slug),
    pgPolicy("projects_app_access", {
      as: "permissive",
      to: appUser,
      for: "all",
      using: sql`true`,
      withCheck: sql`true`,
    }),
    pgPolicy("projects_tenant_isolation", {
      as: "restrictive",
      to: appUser,
      for: "all",
      using: sql`${table.organizationId} = current_setting('app.organization_id', true)`,
      withCheck: sql`${table.organizationId} = current_setting('app.organization_id', true)`,
    }),
  ]
)

Adding a policy enables RLS on the table in Drizzle. If RLS is needed with no policies yet, use pgTable.withRLS(...) — with no policy, Postgres default-denies row access.

The permissive policy admits normal operations, while the restrictive policy is combined with every applicable policy using AND. That prevents a future permissive support policy from accidentally making tenant isolation optional.

Important Postgres caveat: table owners bypass RLS by default; FORCE ROW LEVEL SECURITY subjects the owner to policies. Superusers and roles with BYPASSRLS always bypass them, even with FORCE.

Provision the product runtime role outside Drizzle, keep it from owning schemas or tables, and add grants plus FORCE in a reviewed custom migration:


ALTER ROLE app_user
  NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS;

GRANT USAGE ON SCHEMA public TO app_user;

GRANT SELECT, INSERT, UPDATE, DELETE
  ON TABLE "user", account, session, verification,
    member, invitation, projects
  TO app_user;

GRANT SELECT, INSERT, UPDATE
  ON TABLE organization
  TO app_user;

ALTER TABLE projects FORCE ROW LEVEL SECURITY;

The login credential is supplied by infrastructure or a secret manager, not committed in a migration. Policies do not grant table privileges, so each migration grants only the DML the runtime needs. Do not grant TRUNCATE, DDL, broad REFERENCES, or role-management privileges. RLS does not govern TRUNCATE, and unique/foreign-key checks bypass row filtering, so scope constraints by tenant and avoid exposing raw conflict details. If a future table uses identity or serial sequences, review its sequence grants separately.

Migration discipline still matters. For an existing table, expand and contract: add the tenant column, backfill it, enforce NOT NULL, add policies and grants, verify them under app_user, then deploy code that depends on the boundary. Drizzle role discovery such as entities.roles does not replace custom SQL for attributes, grants, membership, default privileges, or FORCE RLS.


5. Postgres RLS with Drizzle

RLS is useful only if the request path connects as a restricted role. Keep separate credentials:

  • Migration owner — owns schema changes and is never used for product traffic
  • app_user — non-owner login used by Better Auth and business queries; subject to policies on tenant tables; no BYPASSRLS
  • Backup role — tightly controlled read access plus BYPASSRLS for complete logical backups

The request pool connects directly as app_user. That means a query which accidentally runs outside the tenant wrapper fails closed: it has ordinary DML privileges, but current_setting(..., true) returns NULL, so the restrictive tenant policy admits no rows.

The wrapper sets the tenant in a transaction-local setting and runs every tenant query in the same transaction:


import { sql } from "drizzle-orm"
import { db } from "./client"

type Db = typeof db
type Tx = Parameters<Parameters<Db["transaction"]>[0]>[0]

export async function withTenant<T>(
  organizationId: string,
  fn: (tx: Tx) => Promise<T>
): Promise<T> {
  return db.transaction(async (tx) => {
    await tx.execute(sql`
      select set_config('app.organization_id', ${organizationId}, true)
    `)
    return fn(tx)
  })
}

Transaction-local settings matter with pooled connections. Neon, PgBouncer, and warm Lambda environments reuse connections. set_config(..., true) is reverted by PostgreSQL at commit or rollback; do not issue reset SQL in finally, because a failed statement leaves the transaction aborted and cleanup SQL can mask the original error. Never define app.organization_id with ALTER ROLE, ALTER DATABASE, or session-level SET, and do not depend on pool reset hooks.

Still write explicit filters in application code:


const rows = await withTenant(organizationId, (tx) =>
  tx.select().from(projects).where(eq(projects.organizationId, organizationId))
)

RLS is defense in depth, not a license to write ambient queries. This policy protects against omitted filters under a trusted context-propagation path. It does not protect against SQL injection or a compromised runtime that can call set_config with another tenant ID.

Test checklist before calling the system production-ready:

  • Same user, two organizations — call org B's list route after creating in A — A's project must not appear
  • Switch the session's active org concurrently — an explicit org A request must remain bound to A
  • Query as app_user without setting app.organization_id — zero rows
  • Insert with a mismatched organization_id — rejected by withCheck
  • Membership revoked — the explicit membership check fails
  • Commit and roll back tenant A, reuse the pooled connection for tenant B — no A rows
  • Throw a SQL error inside withTenant — preserve the original error
  • Run as the migration owner with FORCE RLS — rows are still filtered
  • Attempt TRUNCATE as app_user — permission denied


6. The Hono Request Pipeline

Middleware order on tenant routes:


CORS
  → require session + explicit organization membership
  → validate path and JSON body
  → check permission for the same organization
  → handler

Typed context keeps handlers honest:


import { APIError } from "better-auth/api"
import { createMiddleware } from "hono/factory"
import { auth } from "../auth"

type Variables = {
  userId: string
  organizationId: string
  memberRole: string
}

export const requireTenant = createMiddleware<{ Variables: Variables }>(
  async (c, next) => {
    const session = await auth.api.getSession({ headers: c.req.raw.headers })

    if (!session) {
      return c.json(
        { code: "UNAUTHORIZED", message: "Authentication is required." },
        401
      )
    }

    const organizationId = c.req.param("organizationId")
    if (!organizationId) {
      return c.json(
        {
          code: "ORGANIZATION_REQUIRED",
          message: "An organization ID is required.",
        },
        400
      )
    }

    try {
      const { role } = await auth.api.getActiveMemberRole({
        headers: c.req.raw.headers,
        query: { organizationId },
      })

      c.set("userId", session.user.id)
      c.set("organizationId", organizationId)
      c.set("memberRole", role)
    } catch (error) {
      if (error instanceof APIError && error.statusCode < 500) {
        return c.json(
          {
            code: "FORBIDDEN",
            message: "You cannot access this organization.",
          },
          403
        )
      }

      throw error
    }

    await next()
  }
)

Prefer POST /organizations/:organizationId/projects over ambient tenant state. Another tab can change the session's active organization while a request is running; an explicit route ID remains immutable for that request. Middleware verifies membership for that ID, permission checks use the same ID, and withTenant receives the same ID.

getActiveMemberRole throws a Better Auth APIError when the organization or membership is invalid; it does not return null. Map expected membership failures to a safe 403 and let unexpected errors reach the centralized error handler.

The Zod OpenAPI route validates both the organization path parameter and JSON body. The handler reads only validated data:


import { APIError } from "better-auth/api"
import { createRoute, z } from "@hono/zod-openapi"

const CreateProjectSchema = z.object({
  name: z.string().trim().min(1).max(120),
  slug: z
    .string()
    .trim()
    .min(1)
    .max(80)
    .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
})

const createProjectRoute = createRoute({
  method: "post",
  path: "/organizations/{organizationId}/projects",
  middleware: [requireTenant] as const,
  request: {
    params: z.object({ organizationId: z.string().min(1) }),
    body: {
      content: {
        "application/json": { schema: CreateProjectSchema },
      },
      required: true,
    },
  },
  responses: {
    201: { description: "Project created" },
    400: { description: "Invalid request" },
    401: { description: "Authentication required" },
    403: { description: "Membership or permission denied" },
    409: { description: "Project slug already exists" },
  },
})

app.openapi(createProjectRoute, async (c) => {
  const { organizationId } = c.req.valid("param")
  const body = c.req.valid("json")

  try {
    const allowed = await auth.api.hasPermission({
      headers: c.req.raw.headers,
      body: {
        organizationId,
        permissions: { project: ["create"] },
      },
    })

    if (!allowed.success) {
      return c.json(
        { code: "FORBIDDEN", message: "Missing project:create permission." },
        403
      )
    }
  } catch (error) {
    if (error instanceof APIError && error.statusCode < 500) {
      return c.json(
        { code: "FORBIDDEN", message: "Permission check failed." },
        403
      )
    }

    throw error
  }

  const [project] = await withTenant(organizationId, (tx) =>
    tx
      .insert(projects)
      .values({
        organizationId,
        name: body.name,
        slug: body.slug,
      })
      .returning()
  )

  return c.json(project, 201)
})

A shared error handler should map malformed or invalid requests to 400, authentication to 401, known authorization failures to 403, tenant-hidden resource lookups to 404, PostgreSQL unique violation 23505 to 409, and unexpected failures to a sanitized 500 with a request ID. A 403 is appropriate for a known operation the caller cannot perform; returning 404 for an unknown resource ID avoids revealing that another tenant owns it.

Membership and permission are checked immediately before the business transaction. The default revocation contract allows an already-authorized in-flight request to finish; revocation blocks subsequent requests. A product requiring immediate revocation must re-check or lock membership inside the same database transaction as the mutation.


7. Production Edge Cases That Break Isolation

These are the cases that look fine in a demo and fail under real traffic:

  • Stale active org — the client UI can still show org A after setActive switched to B. Treat it as display/navigation state only; explicit business routes remain authoritative.
  • Organization deletion — Better Auth hard-deletes organization membership and invitation rows. Other sessions can retain a stale active ID. This example disables direct deletion and uses ON DELETE RESTRICT; an archival workflow must revoke sessions, handle domain retention, and delete in an explicit order.
  • Invitation security — accepting an invitation requires a matching authenticated email. Send opaque IDs only to their recipient, require email verification, expire invitations, and never log action URLs.
  • Cross-tenant IDs in URLsGET /projects/:id must load by id inside the tenant transaction. With RLS, the wrong tenant gets not found, not another org's row. Still filter by organizationId in the query.
  • Background jobs and webhooks — there is no session cookie. Use authenticated or signed payloads, authorize tenant scope before enqueueing, record the actor and operation, make retries idempotent, then run the same withTenant wrapper. A tenant ID alone is not authorization.
  • Support break-glass — use a separate audited admin connection or tool, not SET ROLE bypass sprinkled through product code.
  • Connection pooling — set tenant context only inside transactions with local settings. Safety comes from transaction end, not a pooler's reset hook. Follow the provider's prepared-statement guidance for transaction poolers.
  • Backups and restorespg_dump setting row_security=off does not bypass RLS; it errors when rows would be filtered. Complete logical backups use a dedicated read + BYPASSRLS role. Back up cluster roles separately (for example with pg_dumpall --globals-only) and test that restores recreate grants, policies, ENABLE RLS, and FORCE RLS.
  • Lambda authorizer caching — identity can be cached only within the revocation guarantees the product accepts. Tenant membership and permission are still checked against the explicit route organization in the API.


8. A Minimal Architecture Slice

Target folder shape:


src/
  auth.ts
  auth-client.ts
  permissions.ts
  db/
    schema.ts
    auth-schema.ts
    rls.ts
  middleware/
    tenant.ts
  routes/
    projects.ts

Implement and integration-test this path end to end before adding teams, dynamic roles, or fancy admin tooling:

  1. Sign up / sign in
  2. Create an organization
  3. Invite a member
  4. Set the active organization for UI navigation
  5. POST /organizations/:organizationId/projects inside withTenant
  6. List the same explicit organization — another org's projects remain hidden
  7. Call an admin-only organization route as a member — 403

If step 6 or 7 fails, the system is not multi-tenant yet. It is a single-tenant app with an organization table. The slice is complete only when invitation delivery, list/admin routes, and isolation tests are executable.



9. Final Thoughts

Better Auth organizations provide membership UX: create, invite, roles, active workspace. Postgres RLS provides a data plane that does not trust every query author to remember the tenant filter. Hono is the place those two meet — typed context, explicit middleware, and handlers that open a tenant-scoped transaction before touching business tables.

Start with a shared schema, static roles, and RLS on every tenant-owned table. Add teams or dynamic access control when a real product requirement appears. Keep deploy, OpenAPI, and Lambda details in the backend API note; keep this note about the isolation contract.

The production bar is simple: forgetting a WHERE clause must not become a cross-tenant incident.



References