When tenant isolation isn't enough — defense in depth
The first thing every multi-tenant platform learns: every database query needs a tenant_id filter. Miss one, and tenant A's API key reads tenant B's data.
That's necessary but not sufficient. Two layers we added on top:
Static analysis test in CI
We have a Vitest file that parses every routes/*.routes.ts and looks for prisma.<model>.findUnique / findFirst / update calls that take an id but don't reference tenantId or req.tenant!.id within a 40-line window. The test fails the build if it finds an unscoped query in a money-moving route. There's an allowlist for legitimate cross-tenant routes (admin surfaces, public market data) that we review every release.
Row-level security as a backstop
For the tables we move money through, Postgres row-level security policies enforce tenant_id = current_setting('app.tenant_id', true)::text on every query. The application sets app.tenant_id at the start of each request via a Prisma middleware. If the application code forgets the filter, RLS rejects the query entirely.
The second layer is annoying when it triggers — you get an opaque error from Postgres rather than a clean app-level message. But that's the point. The static check is for catching honest bugs in code review; RLS is for catching the bugs the static check missed.