Multi-tenancy は isolation が WHERE organization_id = ? の習慣だけだと production で失敗する。1 つの filter 漏れ、workspace 間の confused deputy、tenant context を忘れた background job だけで data leak する。
production-grade service には合意する 3 層が必要:
- Identity / membership — user が誰か、どの organization に所属するか、active org、role(Better Auth organization plugin)
- Request context — 保護された Hono handler は verified な
userIdとorganizationIdを受け取る(client 制御 header だけからは決して取らない) - Data boundary — PostgreSQL row-level security が application code が filter を忘れても cross-tenant rows を拒否する
このノートは Hono、Better Auth sessions、Drizzle、PostgreSQL の typed backend を前提とする。base stack は Hono、Drizzle、Zod OpenAPI、SST による Backend API で扱う。ここでは tenant isolation に焦点を当てる。
1. ここでの Production Multi-Tenant の意味
この architecture では tenant は organization:members、roles、独自 business data を持つ SaaS workspace。users は複数 organization に所属できる。session は active な organization を追跡する。
shared database と shared 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 isolation — Postgres 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
→ PostgreSQLresponsibilities は 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 を使う場合、userId と sessionId など小さな 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 に organization、member、invitation、session.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 workflowActive 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 は owner、admin、member。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_id を ALTER ROLE、ALTER 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
→ handlertyped 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 23505 を 409、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 URLs —
GET /projects/:idは tenant transaction 内で id load。RLS では wrong tenant は not found、別 org row ではない。query でもorganizationIdfilter。 - Background jobs と webhooks — session cookie なし。authenticated または signed payloads、enqueue 前に tenant scope authorize、actor と operation record、retries idempotent、同じ
withTenantwrapper。tenant ID だけは authorization ではない。 - Support break-glass — separate audited admin connection または tool。product code に sprinkle した
SET ROLEbypass ではない。 - Connection pooling — tenant context は local settings 付き transactions 内のみ。safety は transaction end から。pooler reset hook ではない。transaction poolers では provider の prepared-statement guidance に従う。
- Backups と restores —
row_security=offのpg_dumpは RLS bypass せず filtered rows なら error。complete logical backups は dedicated read +BYPASSRLSrole。cluster roles は別途 backup(例pg_dumpall --globals-only)。restore が grants、policies、ENABLE RLS、FORCE 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.tsteams、dynamic roles、fancy admin tooling を add する前に end to end で implement と integration-test:
- Sign up / sign in
- Create organization
- Invite member
- Set active organization for UI navigation
POST /organizations/:organizationId/projectsinsidewithTenant- List same explicit organization — 別 org projects は hidden
- 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 にならないこと。