close

DEV Community

Marc
Marc

Posted on

Multi-Tenancy in Bun/Hono Without Boilerplate

Every multi-tenant SaaS has the same problem: you need to make sure every query only returns data for the right tenant. Forget a WHERE tenant_id = ? once, and you have a data leak.

The obvious solution — a separate database per tenant — doesn't scale. Connections are expensive, migration overhead multiplies, and you lose cross-tenant reporting.

For Kumiko we went a different route: a single DB pool, but every query automatically gets the tenantId injected — without handler code ever having to do it manually.

The Idea: TenantDb Instead of a Raw DbRunner

Instead of passing a raw DB connection around, we create a TenantDb wrapper per request:

const db = createTenantDb(rawDb, tenantId)
Enter fullscreen mode Exit fullscreen mode

From that point, db behaves like a normal database — but with automatic isolation baked in:

// Handler code — no tenantId needed
const users = await ctx.db.selectMany(usersTable)
// → SELECT * FROM users WHERE tenant_id IN ('tenant-123', 'system')

await ctx.db.insertOne(usersTable, { name: 'Max' })
// → INSERT INTO users (name, tenant_id) VALUES ('Max', 'tenant-123')

await ctx.db.updateMany(usersTable, { name: 'Moritz' }, { id: userId })
// → UPDATE users SET name='Moritz' WHERE id=? AND tenant_id='tenant-123'
Enter fullscreen mode Exit fullscreen mode

Handlers write plain CRUD code. Isolation happens underneath — invisible, but enforced.

How the Injection Works

Reads: own rows + reference data

Read queries always see two tenants: the current one and SYSTEM_TENANT_ID. This allows reference data (e.g. global config) to be visible to all tenants without duplicating it:

// tenantId filter === [currentTenantId, SYSTEM_TENANT_ID]
Enter fullscreen mode Exit fullscreen mode

If a handler passes its own tenantId in the WHERE clause, it can only narrow the scope, never widen it. A where: { tenantId: 'other-tenant' } is silently dropped.

Writes: own rows only

Inserts get tenantId forced in — and the value cannot be overridden by the caller:

// mode === "tenant": tenantId on INSERT is enforced last
return { ...data, tenantId }  // overwrites whatever the handler passed
Enter fullscreen mode Exit fullscreen mode

Updates and deletes without a WHERE clause throw an error instead of hitting all rows:

// Prevents accidental mass-updates
"TenantDb.updateMany without where would mass-update all tenant rows."
Enter fullscreen mode Exit fullscreen mode

System mode for operators

For admin screens there's r.systemScope() — queries run unfiltered across all tenants. Explicit opt-in only, never the default.

Tenant Resolution in Hono

TenantDb needs a tenantId. It comes from middleware that resolves it from the request — either from the hostname (for custom domains) or from the JWT:

// Middleware (simplified)
app.use('*', async (c, next) => {
  const tenant = await resolveTenant(c.req)
  c.set('db', createTenantDb(rawDb, tenant.id))
  await next()
})
Enter fullscreen mode Exit fullscreen mode

Every handler then gets proper isolation through ctx.db — without a single line of tenant logic in actual feature code.

What This Means in Practice

Across three years of production use and several apps (CashColt, publicstatus, kumiko-studio) we've had zero data leak bugs from forgotten tenant filters. Not because we were particularly careful, but because it's structurally impossible to forget.

The overhead: nearly zero. A few extra conditions per query, no extra DB connection pool, no migration overhead multiplied per tenant.

If you want to try the framework: kumiko.rocks — open source under BUSL-1.1.

Top comments (0)