メインコンテンツへスキップ

Multi-tenancy は isolation が WHERE organization_id = ? の習慣だけだと production で失敗する。1 つの filter 漏れ、workspace 間の confused deputy、tenant context を忘れた background job だけで data leak する。

production-grade service には合意する 3 層が必要:

  1. Identity / membership — user が誰か、どの organization に所属するか、active org、role(Better Auth organization plugin)
  2. Request context — 保護された Hono handler は verified な userIdorganizationId を受け取る(client 制御 header だけからは決して取らない)
  3. Data boundaryPostgreSQL row-level security が application code が filter を忘れても cross-tenant rows を拒否する

このノートは HonoBetter Auth sessions、DrizzlePostgreSQL の typed backend を前提とする。base stack は Hono、Drizzle、Zod OpenAPI、SST による Backend API で扱う。ここでは tenant isolation に焦点を当てる。



1. ここでの Production Multi-Tenant の意味

この architecture では tenantorganization:members、roles、独自 business data を持つ SaaS workspace。users は複数 organization に所属できる。session は active な organization を追跡する。

shared databaseshared schema を使う。tenant-owned row にはすべて organization_id を持つ。common SaaS model で Better Auth organization plugin に直接合う。

isolation には softer と harder な形がある:

  • Soft isolation — application code が常に organizationId で filter。開始は速いが pressure 下で間違えやすい。
  • Database-enforced isolationPostgres RLS が trusted tenant-context path 下で同じ rule を enforce。WHERE を忘れると別 tenant の data ではなく zero rows。

この design が address する threat:

  • 新 query や admin script の tenant filter 漏れ
  • URL に leak した resource ID(/projects/:id)が別 org に属する
  • membership を verify せず X-Organization-Id を accept
  • connection pooling が別 tenant の settings を残した session を reuse
  • production で isolation を静かに disable する break-glass admin path

このノートの non-goals:schema-per-tenant、database-per-tenant、billing や metering。別 architecture。Teams と dynamic access control は Better Auth に存在する。v1 は static roles のまま。



2. Architecture

request path は通常 session auth の上に tenant resolution と RLS-scoped transaction を追加する:


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 は narrow に保つ:

  • Better Auth organization — orgs、members、invitations、activeOrganizationId、roles または permissions。
  • Hono middleware — typed tenant context を attach し、explicit route organization への membership がない request を reject。
  • Drizzle schema — tenant-owned table すべてに organization_id と RLS policies。
  • PostgreSQL — last line of defense。

base ノートの API Gateway Lambda authorizer pattern を使う場合、userIdsessionId など小さな identity identifier のみ渡す。business route は依然 organizationId を supply し、API はその exact organization の membership を verify してから RLS transaction を開く。verification なしに client 制御 header から identity や tenant を copy しない。



3. Tenancy Control Plane としての Better Auth Organization

organization plugin が membership と workspace layer。@better-auth/drizzle-adapter を install し、server と client で plugin を有効化し、migration を generate と apply して PostgreSQL に organizationmemberinvitationsession.activeOrganizationId を作る。


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 },
    }),
  ],
})

invitation callback は opaque invitation ID を intended recipient にのみ deliver する。acceptance には invitation email と一致する authenticated session が必要。email verification を require し、direct organization deletion を disable して deletion を explicit archival workflow 経由にする。

plugin を add または change した後、Better Auth Drizzle schema を generate、SQL migration を generate、review、apply:


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

最初の command は schema definitions を書く。PostgreSQL は変更しない。CI では application の Better Auth version に CLI を pin し、@latest が独立に動かないようにする。

Lifecycle

happy path は短い:


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

Active organization は session state だが、business mutations の authorization input ではなく UI preference として扱う。clients は organization.setActive を call。server は session に activeOrganizationId を store。business routes は依然 explicit organization ID を carry し、その exact ID の membership を verify する。

session 作成時に active org を seed するには、同じ auth configuration に database hook を add:


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 と permissions

default roles は owneradminmember。domain actions には access controller を定義し 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 と middleware は request headers と route と同じ explicit organization ID で auth.api.hasPermission を call:


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 と dynamic access control は product が static roles を outgrow したとき。day one は disabled のまま。



4. Domain Schema:Business Row はすべて Tenant-Owned

auth tables は global:user、account、session、verification、organization、member、invitation。business tables は tenant-owned

rule は simple:row が workspace に属するなら non-null organization_id foreign key to organization.id、必要なら tenant-scoped uniqueness、real tenant access paths 向け index。

Better Auth organization ID は string なので domain foreign keys は uuid ではなく text(ID generation を customize していない限り)。


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)`,
    }),
  ]
)

policy を add すると Drizzle で table の RLS が有効になる。policy なしで RLS が必要なら pgTable.withRLS(...)——policy なしでは Postgres が default-deny row access。

permissive policy が normal operations を admit。restrictive policy は applicable policy すべてと AND で combine。将来の permissive support policy が tenant isolation を optional にしない。

重要な Postgres caveat: table owner は default で RLS を bypass;FORCE ROW LEVEL SECURITY が owner を policies に subject する。superusers と BYPASSRLS role は FORCE でも bypass。

product runtime role は Drizzle 外で provision し、schemas や tables を own させず、reviewed custom migration で grants と FORCE を add:


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;

login credential は infrastructure または secret manager から供給し migration に commit しない。policies は table privileges を grant しないので各 migration は runtime が必要な DML のみ grant。TRUNCATE、DDL、broad REFERENCES、role-management privileges は grant しない。RLS は TRUNCATE を govern せず、unique/foreign-key checks は row filtering を bypass するので constraints を tenant で scope し raw conflict details を expose しない。将来 identity または serial sequences を使う table では sequence grants を別途 review。

migration discipline も重要。existing table では expand and contract:tenant column add、backfill、NOT NULL enforce、policies と grants add、app_user 下で verify、boundary に依存する code を deploy。Drizzle role discovery の entities.roles は attributes、grants、membership、default privileges、FORCE RLS 用 custom SQL の代わりにはならない。


5. Drizzle による Postgres RLS

RLS は request path が restricted role で connect するときのみ有用。credentials を分ける:

  • Migration owner — schema changes を own し product traffic には使わない
  • app_user — Better Auth と business queries 用 non-owner login;tenant tables の policies subject;BYPASSRLS なし
  • Backup role — tightly controlled read access と complete logical backups 用 BYPASSRLS

request pool は app_user として直接 connect。tenant wrapper 外で accidental query は fail closed:ordinary DML privileges はあるが current_setting(..., true)NULL なので restrictive tenant policy は rows を admit しない。

wrapper は transaction-local setting で tenant を set し、tenant query を同一 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 は pooled connections で重要。Neon、PgBouncer、warm Lambda environments は connections を reuse。set_config(..., true) は commit または rollback で PostgreSQL が revert;finally で reset SQL を issue しない。failed statement は transaction aborted のままになり cleanup SQL が original error を mask する。app.organization_idALTER ROLEALTER DATABASE、session-level SET で定義せず、pool reset hooks に依存しない。

application code でも explicit filters を書く:


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

RLS は defense in depth であり ambient queries を書く license ではない。この policy は trusted context-propagation path 下の omitted filters に対する。SQL injection や別 tenant ID で set_config を call できる compromised runtime には protect しない

production-ready と呼ぶ前の test checklist:

  • 同一 user、2 organization — A で create 後 org B list route — A の project は現れない
  • session active org を concurrent に switch — explicit org A request は A に bind されたまま
  • app.organization_id を set せず app_user として query — zero rows
  • mismatched organization_id で insert — withCheck で reject
  • membership revoked — explicit membership check が fail
  • tenant A を commit と rollback、pooled connection を tenant B に reuse — A rows なし
  • withTenant 内で SQL error throw — original error を preserve
  • migration owner で FORCE RLS — rows は依然 filtered
  • app_user として TRUNCATE 試行 — permission denied


6. Hono Request Pipeline

tenant routes の middleware order:


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

typed context が 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()
  }
)

ambient tenant state より POST /organizations/:organizationId/projects を prefer。別 tab が request 実行中に session active organization を change できる。explicit route ID はその request では immutable。middleware がその ID の membership を verify。permission checks も同 ID。withTenant も同 ID を受け取る。

getActiveMemberRole は organization または membership が invalid のとき Better Auth APIError を throw;null は返さない。expected membership failures を safe 403 に map。unexpected errors は centralized error handler へ。

Zod OpenAPI route は organization path parameter と JSON body の両方を validate。handler は 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)
})

shared error handler は malformed または invalid requests を 400、authentication を 401、known authorization failures を 403、tenant-hidden resource lookups を 404、PostgreSQL unique violation 23505409、unexpected failures を sanitized 500 と request ID に map。caller が perform できない known operation には 403。unknown resource ID には 404 で別 tenant ownership を reveal しない。

membership と permission は business transaction の直前に check。default revocation contract は already-authorized in-flight request の finish を許可。revocation は subsequent requests を block。immediate revocation が必要な product は mutation と同一 database transaction 内で membership を re-check または lock。


7. Isolation を Break する Production Edge Cases

demo では fine に見え real traffic で fail する cases:

  • Stale active org — client UI は setActive で B に switch 後も org A を表示しうる。display/navigation state のみ。explicit business routes が authoritative。
  • Organization deletion — Better Auth は organization membership と invitation rows を hard-delete。他 session は stale active ID を retain。この例は direct deletion を disable し ON DELETE RESTRICT。archival workflow が sessions revoke、domain retention、explicit order delete を handle する必要。
  • Invitation security — acceptance には matching authenticated email。opaque ID は recipient のみ、email verification require、invitations expire、action URLs を log しない。
  • Cross-tenant IDs in URLsGET /projects/:id は tenant transaction 内で id load。RLS では wrong tenant は not found、別 org row ではない。query でも organizationId filter。
  • Background jobs と webhooks — session cookie なし。authenticated または signed payloads、enqueue 前に tenant scope authorize、actor と operation record、retries idempotent、同じ withTenant wrapper。tenant ID だけは authorization ではない。
  • Support break-glass — separate audited admin connection または tool。product code に sprinkle した SET ROLE bypass ではない。
  • Connection pooling — tenant context は local settings 付き transactions 内のみ。safety は transaction end から。pooler reset hook ではない。transaction poolers では provider の prepared-statement guidance に従う。
  • Backups と restoresrow_security=offpg_dump は RLS bypass せず filtered rows なら error。complete logical backups は dedicated read + BYPASSRLS role。cluster roles は別途 backup(例 pg_dumpall --globals-only)。restore が grants、policies、ENABLE RLSFORCE RLS を recreate するか test。
  • Lambda authorizer caching — identity は product が accept する revocation guarantees 内でのみ cache。tenant membership と permission は API で explicit route organization に対して依然 check。


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

teams、dynamic roles、fancy admin tooling を add する前に end to end で implement と integration-test:

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

step 6 または 7 が fail なら multi-tenant ではない。organization table 付き single-tenant app。invitation delivery、list/admin routes、isolation tests が executable なら slice complete。



9. Final Thoughts

Better Auth organizations は membership UX:create、invite、roles、active workspace。Postgres RLS は every query author の tenant filter を trust しない data plane。Hono は両者が meet する場所——typed context、explicit middleware、business tables に触れる前に tenant-scoped transaction を open する handlers。

shared schema、static roles、tenant-owned table すべてに RLS から start。real product requirement が現れたら teams または dynamic access control。deploy、OpenAPI、Lambda details は backend API ノート。このノートは isolation contract。

production bar は simple:WHERE clause を忘れても cross-tenant incident にならないこと。



References

次のノートを読む
Frontend Regression Tests