Skip to content
Back

Building a Multi-Tenant Backend with Hono, Better Auth, Drizzle, and Postgres RLS

Backend

How to isolate tenants with Better Auth organizations, Hono request context, and Postgres row-level security on a shared Drizzle schema

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


text
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_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.

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.


src/auth.ts
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 organizationClient on the client with the same ac and 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:


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

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.


ts
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:


src/permissions.ts
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.
  • 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_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.
  • Uniqueness is (organization_id, slug), not a global slug.

src/db/schema.ts
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 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:


sql
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, broad REFERENCES, 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 under app_user, then deploy code that depends on the boundary. Drizzle 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, 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, 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.

src/db/rls.ts
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_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. 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_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.


src/middleware/tenant.ts
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/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:


src/routes/projects.ts
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 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. Map hasPermission APIError with statusCode < 500 to 403. 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 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 URLs — GET /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 restores — pg_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, then implement and integration-test this path end to end before adding teams, dynamic roles, or fancy admin tooling.


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

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


Recap Q&A