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: identity / membership (Better Auth organization plugin), request context (every protected Hono handler receives a verified userId and organizationId, never from a client-controlled header alone), and a data boundary (PostgreSQL row-level security denies cross-tenant rows even if application code forgets a filter).
This note assumes the typed stack in Backend APIs with Hono, Drizzle, Zod OpenAPI, and SST. Here the focus is tenant isolation.
1. What Production Multi-Tenant Means Here
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. - Soft isolation — application code always filters by
organizationId. Fast to start, easy to get wrong under pressure. - Database-enforced isolation — Postgres RLS enforces the same rule under a trusted tenant-context path. A forgotten
WHEREreturns 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-Idwithout 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: schema-per-tenant, database-per-tenant, and billing or metering. 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- 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_idand 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.
Failure: copying 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,
})
},
}),
],
})- Enable
organizationClienton the client with the sameacand roles. - 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.
- Lifecycle: create organization → invite member → accept invitation → set-active organization → work inside that tenant → leave or archive through an explicit 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
@latestto move independently.
Active organization is session state, but treat it 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.
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 } })],
})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.hasPermissionwith the request headers and the same explicit organization ID used by the route. - 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.
- If the row belongs to a workspace, it has a non-null
organization_idforeign key toorganization.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, notuuid, unless ID generation has been customized. - Uniqueness is
(organization_id, slug), not a global slug.
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. The restrictive policy is combined with every applicable policy using
AND, so a future permissive support policy cannot make tenant isolation optional. - Table owners bypass RLS by default;
FORCE ROW LEVEL SECURITYsubjects the owner to policies. Superusers and roles withBYPASSRLSalways bypass them, even withFORCE.
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: grant only the DML the runtime needs. Do not grant
TRUNCATE, DDL, broadREFERENCES, or role-management privileges. - RLS does not govern
TRUNCATE. Unique and foreign-key checks bypass row filtering, so scope constraints by tenant and avoid exposing raw conflict details. Review sequence grants if a table uses identity or serial. - For an existing table, expand and contract: add the tenant column, backfill it, enforce
NOT NULL, add policies and grants, verify them underapp_user, then deploy code that depends on the boundary. Drizzleentities.rolesdoes not replace custom SQL for attributes, grants, membership, default privileges, orFORCE 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, never used for product traffic), app_user (non-owner login used by Better Auth and business queries; no BYPASSRLS), and a backup role (tightly controlled read access plus BYPASSRLS for complete logical backups).
- The request pool connects directly as
app_user. A query that accidentally runs outside the tenant wrapper fails closed: it has ordinary DML privileges, butcurrent_setting(..., true)returnsNULL, 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: a failed statement leaves the transaction aborted and cleanup SQL can mask the original error. - Never define
app.organization_idwithALTER ROLE,ALTER DATABASE, or session-levelSET, and do not depend on pool reset hooks. - Still write explicit filters in application code. RLS is defense in depth, not a license to write ambient queries.
Failure: treating RLS as protection against SQL injection or a compromised runtime that can call set_config with another tenant ID. It only protects against omitted filters under a trusted context-propagation path.
Test 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_userwithout settingapp.organization_id— zero rows - Insert with a mismatched
organization_id— rejected bywithCheck - 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
TRUNCATEasapp_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.
import { APIError } from "better-auth/api"
import { createMiddleware } from "hono/factory"
import type { Context } from "hono"
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)
}
const denied = await bindTenant(c, session.user.id, organizationId)
if (denied) return denied
await next()
}
)
async function bindTenant(
c: Context<{ Variables: Variables }>,
userId: string,
organizationId: string
) {
try {
const { role } = await auth.api.getActiveMemberRole({
headers: c.req.raw.headers,
query: { organizationId },
})
c.set("userId", userId)
c.set("organizationId", organizationId)
c.set("memberRole", role)
return null
} catch (error) {
if (error instanceof APIError && error.statusCode < 500) {
return c.json({ code: "FORBIDDEN", message: "You cannot access this organization." }, 403)
}
throw error
}
}- Prefer
POST /organizations/:organizationId/projectsover 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
withTenantreceives the same ID. getActiveMemberRolethrows a Better AuthAPIErrorwhen the organization or membership is invalid; it does not returnnull. Map expected membership failures to a safe403and 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:
app.openapi(createProjectRoute, async (c) => {
const { organizationId } = c.req.valid("param")
const body = c.req.valid("json")
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
)
}
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 requests to
400, authentication to401, known authorization failures to403, tenant-hidden resource lookups to404, PostgreSQL unique violation23505to409, and unexpected failures to a sanitized500with a request ID. - A
403is appropriate for a known operation the caller cannot perform; returning404for an unknown resource ID avoids revealing that another tenant owns it. - Membership and permission are checked immediately before the business transaction. Map
hasPermissionAPIErrorwithstatusCode < 500to403. The default revocation contract allows an already-authorized in-flight request to finish; revocation blocks subsequent requests.
Failure: requiring immediate revocation without re-checking or locking 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
setActiveswitched 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 URLs —
GET /projects/:idmust load by id inside the tenant transaction. With RLS, the wrong tenant gets not found, not another org’s row. Still filter byorganizationIdin 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
withTenantwrapper. A tenant ID alone is not authorization. - Support break-glass — use a separate audited admin connection or tool, not
SET ROLEbypass 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 restores —
pg_dumpsettingrow_security=offdoes not bypass RLS; it errors when rows would be filtered. Complete logical backups use a dedicated read +BYPASSRLSrole. Back up cluster roles separately (for example withpg_dumpall --globals-only) and test that restores recreate grants, policies,ENABLE RLS, andFORCE 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, then implement and integration-test this path end to end before adding teams, dynamic roles, or fancy admin tooling.
src/
auth.ts
auth-client.ts
permissions.ts
db/
schema.ts
auth-schema.ts
rls.ts
middleware/
tenant.ts
routes/
projects.ts- Sign up / sign in
- Create an organization
- Invite a member
- Set the active organization for UI navigation
POST /organizations/:organizationId/projectsinsidewithTenant- List the same explicit organization — another org’s projects remain hidden
- 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. 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
- Better Auth Drizzle adapter
- Better Auth organization plugin
- Drizzle ORM row-level security
- PostgreSQL row security policies
- PostgreSQL configuration functions
- PostgreSQL logical backups with pg_dump