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)
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'
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]
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
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."
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()
})
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)