Prisma findUnique quietly bypasses tenant scoping
A Prisma middleware that injects `where.tenantId` on every read looks airtight — until you remember `findUnique` rejects non-unique fields in its where. So `findUnique({ where: { id } })` runs unscoped and happily returns another tenant's row. The middleware never errors; it just silently does nothing. Route all tenant-scoped single lookups through `findFirst` instead.
The standard way to enforce tenant isolation at the data layer is a Prisma middleware that
rewrites queries: for any tenant-scoped model, inject where.tenantId = <current tenant> on
reads and stamp tenantId on writes. Do it once, centrally, and no handler can forget it.
if (READ_ACTIONS.has(action)) {
params.args.where = { ...(params.args.where ?? {}), tenantId };
}
This is correct for findMany, findFirst, count, aggregate, updateMany, deleteMany.
It is silently wrong for findUnique.
Prisma’s findUnique only accepts fields that are part of a unique constraint in its where.
id is unique; tenantId is not. Injecting tenantId into a findUnique where clause is
either rejected by Prisma or — depending on how you merge — dropped. Either way the query you
actually run is findUnique({ where: { id } }), with no tenant filter at all. Tenant A
requests tenant B’s record by id and gets it back. The middleware didn’t throw, didn’t warn —
it just had nothing valid to do and got out of the way.
The trap is that the middleware looks exhaustive. It handles every action in one place, which
is exactly what lulls you: you assume “all reads are scoped” when really “all reads that accept
a non-unique where are scoped.” findUnique and findUniqueOrThrow are the exceptions, and
they’re the ones most likely to carry an untrusted id straight from a URL param.
The fix is to ban findUnique for tenant-scoped models and route those lookups through
findFirst, which accepts arbitrary where fields and therefore takes the injected filter:
// ❌ leaks across tenants — findUnique can't be scoped
const row = await prisma.record.findUnique({ where: { id } });
// ✅ scoped — findFirst accepts the injected tenantId
const row = await prisma.record.findFirst({ where: { id } });
// → SELECT ... WHERE id = $1 AND "tenantId" = $2
Same logic for single-row mutations: update/delete use a unique where, so they can’t be
scoped either — use updateMany/deleteMany (which take the filter and simply affect zero rows
for a foreign tenant), or verify ownership with a scoped findFirst before mutating.
Two things made this cheap to catch and worth locking down:
- Encode the rule where it’s enforced. A comment on the middleware listing exactly which
actions are scoped and why
findUniqueis excluded turns a silent gap into a documented contract the next person can’t miss. - Prove it with a cross-tenant test, not a unit test. Unit-testing the pure scoping
transform is nice, but it can’t catch this — the transform is correct, it’s just never
invoked for
findUnique. The test that matters logs in as tenant A, grabs a real id from tenant B, requests it, and asserts a 404. That’s the one that fails loudly if someone reaches forfindUniquelater.
Centralizing isolation in middleware is still the right call — you just have to know that Prisma gives you two families of read APIs with different where semantics, and only one of them can be transparently scoped.