Multi-tenant isolation you cannot forget to apply
- Architecture
- MongoDB
- Security

In a multi-tenant application, the query that leaks one customer's data to another is never exotic. It is a normal query written by a competent developer on a Thursday, missing one clause.
// Correct.
const students = await Student.find({ instituteId, batchId });// One word shorter, and now every institute's students. const students = await Student.find({ batchId }); ```
Code review catches most of these. "Most" is not a security posture. The interesting question is not how to write the filter correctly — it is how to make omitting it impossible.
Discipline does not scale, defaults do
The strategies people reach for first are all forms of remembering harder: a checklist, a naming convention, a lint rule. They decay, because they depend on every future developer — including the one onboarding next quarter under deadline — holding the same context.
What works is moving the guarantee somewhere it applies whether or not anyone was thinking about it.
Bind the tenant to the request, not the query
Resolve the tenant once, at the edge, from the authenticated session — never from a request body or a query parameter a client could set. Put it somewhere request-scoped that a data helper can read without being passed it explicitly.
Then make the only way to reach a collection go through a helper that applies the scope:
function scoped(model, tenantId) {
return {
find: (filter = {}, ...rest) => model.find({ ...filter, tenantId }, ...rest),
findOne: (filter = {}, ...rest) => model.findOne({ ...filter, tenantId }, ...rest),
updateOne: (filter = {}, ...rest) => model.updateOne({ ...filter, tenantId }, ...rest),
};
}Note the spread order. The tenant filter goes after the caller's filter, so a caller who passes their own tenantId — deliberately or by accident — cannot widen the scope. It is not a suggestion the caller can override.
Make the raw model hard to reach
A helper only helps if it is the path of least resistance. If the raw model is still exported and importable, someone will use it, usually while debugging something unrelated at 11pm.
Keep the raw model private to the data layer and export only the scoped accessor. If a genuinely cross-tenant operation is needed — a platform-wide admin report, a migration — give it a separate, loudly named entry point that reads as a deliberate act at the call site.
Test the wall, not just the feature
Feature tests prove that a user can see their own data. They never prove that a user cannot see somebody else's, because the test fixture usually contains one tenant.
Write the adversarial half explicitly. Seed two tenants. For every endpoint, authenticate as tenant A and request tenant B's identifiers — by id, by slug, through search, through any filter the API accepts. Assert 404, not 403: a 403 confirms the record exists, which is itself a leak.
These tests are boring to write and they are the ones that matter. A meaningful share of a mature suite should exist for no reason other than trying to break the isolation.
Scope the cache too
The failure mode that survives all of the above is a cache key that forgot the tenant. Tenant A requests a dashboard, it caches under dashboard:summary, tenant B requests it and gets A's numbers — served correctly, from a query that was perfectly scoped.
Every cache key in a multi-tenant system needs the tenant in it. Every one. This is the leak that is hardest to spot in review, because the query above it looks right.