🚨 If you are building a B2B SaaS, your biggest nightmare isn't downtime—it's a cross-tenant data leak.
Most tutorials teach you to handle multi-tenancy like this:
// ❌ The Junior Developer Approach
const data = await db.query.invoices.findMany({
where: eq(invoices.orgId, req.body.orgId)
});
💥 This is a ticking time bomb.
It relies on the developer remembering to append the orgId check on every single database query. If a developer forgets it on one endpoint, Tenant A just saw Tenant B's invoices.
Here is how you build true multi-tenant isolation that senior engineers actually trust.
🛡️ 1. The Principle of Zero Trust in the Application Layer
Your application logic should not be responsible for tenant isolation. The isolation must happen at the middleware or database level.
When a request comes in, the context of who is asking and which organization they belong to must be established before the route handler is even executed.
⚙️ 2. The Implementation: Hono + Drizzle + Better Auth
In modern architectures, we can leverage middleware to inject the tenant context into the request lifecycle. Here is how we handle it in our stack.
Step 1: Validate and Extract the Tenant
Every request passes through an authentication middleware. If the token is valid, we extract the activeOrganizationId.
// ✅ The Architect Approach (Hono Middleware)
import { createMiddleware } from 'hono/factory';
export const tenantAuthMiddleware = createMiddleware(async (c, next) => {
// Extract session securely from cookies/headers
const session = await betterAuth.getSession(c.req);
if (!session || !session.activeOrganizationId) {
// Failure Handling: Explicitly reject missing tenant contexts
return c.json({ error: "Unauthorized: Missing organization context." }, 401);
}
// Inject the trusted org ID into the request context
c.set("orgId", session.activeOrganizationId);
await next();
});
Step 2: Enforced Database Context
Now, inside your route, you don't rely on the client payload. You rely on the strictly validated context.
app.get("/api/protected/invoices", tenantAuthMiddleware, async (c) => {
const orgId = c.get("orgId"); // Guaranteed to be valid and authorized
// The DB query relies on the trusted middleware context, not req.body
const tenantInvoices = await db
.select()
.from(invoices)
.where(eq(invoices.orgId, orgId));
return c.json(tenantInvoices);
});
🚦 3. Handling Failure States
What happens if the service-to-service call fails, or the JWT expires mid-flight?
- 🔴 Token Expired: The middleware catches the expired session and returns a
401 Unauthorizedbefore hitting the database. The frontend is forced to refresh the session. - 🔴 Tenant Mismatch: If a user tries to access a resource belonging to
Org Bbut their token resolves toOrg A, the middleware throws a403 Forbidden. The database is never touched.
🐘 4. Going Further: Row-Level Security (RLS)
For absolute paranoia, you push this logic down into PostgreSQL itself using Row-Level Security (RLS).
You set the Postgres session variable app.current_tenant to the orgId upon connection, and Postgres physically blocks any query trying to read rows outside that ID, even if the application developer blindly writes select * from invoices.
🎯 The Takeaway
Stop building SaaS templates that rely on application-level if statements for security.
I got tired of auditing codebases with these vulnerabilities, so I built an open-source monorepo that enforces these boundaries by default. It separates the Vite frontend from the Hono API, uses Drizzle ORM, and strictly isolates tenant data at the middleware level using Better Auth.
👉 If you want to see the full production implementation of this architecture, check out the organization-v2 branch of FlowStack on my GitHub.
Don't let framework magic make you lazy about security boundaries.
Top comments (1)
the RLS approach is underrated for this. most teams stop at middleware and feel safe, but RLS gives you a second enforcement layer that doesn't care if someone fat-fingers a query. been using this pattern on a tool I've been building and the peace of mind is real. one gotcha though: if you're using a connection pooler like pgbouncer in transaction mode, setting session variables for the tenant context can get messy. worth testing that carefully before going to prod.