Prisma Client Extensions for Tenant Scoping
A single Prisma query that forgets its where: { tenantId } clause is a cross-tenant data leak, and relying on every developer to remember it on every call is not a control. This guide sits within ORM middleware for multi-tenancy and shows how to use Prisma Client Extensions to inject the tenant predicate automatically on reads, writes, and deletes, with database row-level security as the backstop for when the application logic is wrong.
Problem Framing
Prisma's type-safe query API is convenient precisely because it lets you write prisma.invoice.findMany() without thinking about the schema underneath. In a multi-tenant system that convenience is a liability. Every findMany, findUnique, update, delete, count, and aggregate must be constrained to the caller's tenant, and the constraint must hold for nested writes, updateMany, and raw filters too. Manually appending where: { tenantId } works until the one call site that forgets it ships to production, and then a list endpoint returns every tenant's rows at once.
The right place to enforce the predicate is below the call sites, in the client itself. Prisma Client Extensions let you wrap the query lifecycle and rewrite arguments before they reach the database. The extension reads the active tenant from request-scoped context, merges a tenantId filter into the incoming args, and forwards the modified query. Because the rewrite happens inside the client, no application code can bypass it by accident: a developer who calls findMany() with no where still gets a tenant-scoped query.
This is application-layer enforcement, and application-layer enforcement is never sufficient on its own. A raw query ($queryRaw), a query the extension does not cover, or a bug in the merge logic all escape the filter. That is why the extension is paired with PostgreSQL row-level security as a second, independent boundary. The extension makes the common path correct and ergonomic; RLS makes the database refuse to return another tenant's rows even when the application is wrong. Two layers, two different failure surfaces.
A note on history: before Prisma 4.16 the only interception hook was $use middleware, which is now deprecated. Middleware ran for every operation as an opaque (params, next) function with weak typing on params.args. Client Extensions replace it with a typed, composable query component scoped per model and operation. New code should use $extends; the contrast matters because most older tenant-scoping guides still show the $use pattern, which Prisma now recommends migrating away from.
Step-by-Step Guide
1. Add tenantId to the schema and require it
Every tenant-owned model needs a tenantId column and an index that leads with it. The composite index keeps tenant-scoped lookups fast and supports the RLS predicate.
model Invoice {
id String @id @default(uuid())
tenantId String
amount Decimal
createdAt DateTime @default(now())
@@index([tenantId, createdAt])
}
2. Resolve tenant context per request
The tenant identifier must come from an authenticated source, never a raw client header. Store it in AsyncLocalStorage so the extension can read it without threading it through every function signature.
import { AsyncLocalStorage } from "node:async_hooks";
type TenantStore = { tenantId: string };
export const tenantContext = new AsyncLocalStorage<TenantStore>();
export function runWithTenant<T>(tenantId: string, fn: () => T): T {
return tenantContext.run({ tenantId }, fn);
}
export function currentTenantId(): string {
const store = tenantContext.getStore();
if (!store) throw new Error("No tenant context bound for this call");
return store.tenantId;
}
3. Build the query-component extension
The query component intercepts operations per model. Inject the tenant filter into reads and *Many writes, and stamp tenantId onto creates. Use $allModels plus a runtime guard if every model is tenant-owned, or list models explicitly.
import { Prisma } from "@prisma/client";
import { currentTenantId } from "./tenant-context";
export const tenantScope = Prisma.defineExtension((client) =>
client.$extends({
query: {
invoice: {
async $allOperations({ operation, args, query }) {
const tenantId = currentTenantId();
if (["findMany", "findFirst", "updateMany", "deleteMany", "count", "aggregate"].includes(operation)) {
args.where = { ...args.where, tenantId };
}
if (["findUnique", "update", "delete"].includes(operation)) {
args.where = { ...args.where, tenantId };
}
if (operation === "create") {
args.data = { ...args.data, tenantId };
}
if (operation === "createMany") {
const rows = Array.isArray(args.data) ? args.data : [args.data];
args.data = rows.map((r) => ({ ...r, tenantId }));
}
return query(args);
},
},
},
})
);
4. Create one extended client per request
$extends returns a new client; it does not mutate the original. Because the tenant is read from AsyncLocalStorage, you can build the extended client once at startup and let context vary per request. Keep a single base PrismaClient (its connection pool is process-wide) and apply the extension to it.
import { PrismaClient } from "@prisma/client";
import { tenantScope } from "./tenant-scope";
const base = new PrismaClient();
export const db = base.$extends(tenantScope);
If you instead need per-request configuration that cannot come from context, build the extended client inside the request handler, but reuse the same base client so you do not open a new connection pool per request:
export function tenantDb() {
return base.$extends(tenantScope); // cheap: shares base's pool
}
5. Wire RLS as the backstop
Enable RLS on the same table and set a per-transaction session variable that the policy reads. The extension that follows sets app.tenant_id before each query so Postgres enforces the boundary independently.
ALTER TABLE "Invoice" ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON "Invoice"
USING (tenant_id = current_setting('app.tenant_id', true)::text);
const rlsClient = base.$extends({
query: {
$allOperations: async ({ args, query }) => {
const tenantId = currentTenantId();
return base.$transaction(async (tx) => {
await tx.$executeRaw`SELECT set_config('app.tenant_id', ${tenantId}, true)`;
return query(args);
});
},
},
});
6. Bind context at the edge
Wrap each request so every downstream query runs inside the tenant store. This is the only place the raw identity is reconciled against the authenticated claim.
app.use((req, res, next) => {
const tenantId = req.auth.claims.tenant_id; // from verified JWT, not a header
runWithTenant(tenantId, () => next());
});
Verification
Prove that a forgotten filter cannot leak. Seed two tenants, then query without any explicit where and assert you only see your own rows. Run the same assertion through the extended client.
test("findMany is tenant-scoped without an explicit where", async () => {
await runWithTenant("tenant-a", () => db.invoice.create({ data: { amount: 10 } }));
await runWithTenant("tenant-b", () => db.invoice.create({ data: { amount: 20 } }));
const seenByB = await runWithTenant("tenant-b", () => db.invoice.findMany());
expect(seenByB).toHaveLength(1);
expect(seenByB[0].amount).toBe(20); // tenant B never sees tenant A's row
});
To confirm the RLS backstop actually fires, bypass the extension with a raw query as the wrong tenant and assert zero rows return:
const leaked = await runWithTenant("tenant-b", () =>
base.$queryRaw`SELECT id FROM "Invoice" WHERE amount = 10` // tenant A's row
);
expect(leaked).toHaveLength(0); // RLS blocks it even though the extension was skipped
If the raw query returns the row, RLS is not enabled or app.tenant_id was never set on that connection.
Failure Modes & Gotchas
- Symptom: A list endpoint returns rows from multiple tenants. Root cause: the operation (e.g.
groupByor a nested relation read) is not handled by the extension's operation list. Fix: cover it in$allOperations, and keep RLS on so the gap fails closed instead of leaking. - Symptom:
No tenant context bound for this callfrom a background job. Root cause: off-request code runs outsideAsyncLocalStorage. Fix: wrap the job body inrunWithTenant()exactly as the request middleware does. - Symptom: RLS returns zero rows for valid queries. Root cause:
set_configran on a different pooled connection than the query, common with transaction-mode poolers. Fix: set the variable inside the same$transactionas the query (Step 5), using the localtrueflag. - Symptom: Old
$usemiddleware and the new extension both run, double-filtering. Root cause: a partially migrated codebase. Fix: remove all$usecalls; the deprecated middleware and$extendsare not meant to coexist for the same concern.
FAQ
Should I use $use middleware or Client Extensions for tenant scoping?
Use Client Extensions. $use middleware is deprecated as of Prisma 4.16, has weaker typing on params.args, and runs globally rather than per model. The query component in $extends is the supported, typed replacement and should be the default for all new tenant-scoping code.
Do I still need row-level security if the extension injects the filter?
Yes. The extension only protects operations it covers and is bypassed entirely by $queryRaw and any uncovered path. RLS is an independent database-level boundary that fails closed when the application logic is wrong, so the two layers are complementary, not redundant.
Does $extends create a new connection pool per request?
No. $extends returns a lightweight extended client that shares the underlying base PrismaClient and its connection pool. Build one base client at startup, apply the extension, and let the tenant vary through request-scoped context.