Almost every SaaS product we are asked to build on the MEAN stack is multi-tenant: one deployment, many customer organisations, strict separation of their data. The dangerous part is not the feature work, it is the one query somewhere in month nine that forgets tenantId and shows Acme's invoices to Globex. This tutorial shows the pattern we actually ship: a tenant resolved once per request, propagated through an AsyncLocalStorage context, and enforced by a Mongoose 8 plugin so that forgetting the filter is impossible rather than merely discouraged. Versions used: Node 24 LTS, Express 5, Mongoose 8 on MongoDB 8, Angular 22.
Choosing an isolation model
Three models are common, and picking the wrong one is expensive to undo.
- Database per tenant. Strongest isolation, easy per-tenant restore, but connection-pool pressure and painful migrations once you pass a few hundred tenants. Right for a handful of large enterprise customers.
- Collection per tenant. Rarely worth it. You get the migration pain of database-per-tenant with weaker isolation.
- Shared collections with a
tenantIddiscriminator. One database, every document carrying its owner. Cheapest to operate, scales to thousands of tenants, and the model this tutorial uses. The whole risk sits in query hygiene, which is exactly what we are going to automate away.
A workable hybrid: shared collections by default, with the option to move a large customer into a dedicated database later. Keep tenant resolution abstracted behind one function and that migration stays tractable.
Step 1: resolve the tenant once
Tenants arrive by subdomain (acme.app.example.com), by path prefix, or by a claim in the access token. Use the token as the source of truth and treat the subdomain as a hint only, otherwise a user with a valid token can read another tenant's data simply by changing the host header.
// api/src/tenant-context.js
import { AsyncLocalStorage } from 'node:async_hooks';
export const tenantStore = new AsyncLocalStorage();
export function currentTenantId() {
const ctx = tenantStore.getStore();
if (!ctx?.tenantId) throw new Error('No tenant in context');
return ctx.tenantId;
}
export function runInTenant(ctx, fn) {
return tenantStore.run(ctx, fn);
}
AsyncLocalStorage is stable in Node 24 and survives await boundaries, so any code called from a request handler can ask for the current tenant without threading a parameter through every function signature.
// api/src/tenant-middleware.js
import { runInTenant } from './tenant-context.js';
import { Membership } from './models.js';
export async function withTenant(req, res, next) {
const claimed = req.get('x-tenant-id') ?? req.user?.tenantId;
if (!claimed) return res.status(400).json({ error: 'tenant not specified' });
// Never trust the header on its own: confirm this user belongs to this tenant.
const membership = await Membership.findOne({
user: req.user.sub,
tenant: claimed,
status: 'active',
}).lean();
if (!membership) return res.status(403).json({ error: 'not a member of this tenant' });
runInTenant({ tenantId: String(membership.tenant), role: membership.role }, next);
}
Two details matter. The membership lookup is the authorisation boundary; the token alone is not enough once a user can belong to several tenants. And runInTenant(..., next) wraps the rest of the middleware chain, so the context is live for every downstream handler.
Step 2: make the filter automatic with a Mongoose plugin
Now the important part. Instead of asking developers to remember { tenantId } in 200 query sites, apply it in one plugin.
// api/src/tenant-plugin.js
import { currentTenantId } from './tenant-context.js';
const READ_OPS = [
'find', 'findOne', 'findOneAndUpdate', 'findOneAndDelete', 'findOneAndReplace',
'count', 'countDocuments', 'distinct', 'updateOne', 'updateMany',
'deleteOne', 'deleteMany', 'replaceOne',
];
export function tenantPlugin(schema) {
schema.add({
tenantId: { type: String, required: true, index: true },
});
schema.pre(READ_OPS, function () {
if (this.getOptions().skipTenantScope) return; // for admin/backfill jobs only
this.where({ tenantId: currentTenantId() });
});
schema.pre('aggregate', function () {
if (this.options?.skipTenantScope) return;
this.pipeline().unshift({ $match: { tenantId: currentTenantId() } });
});
schema.pre('save', function () {
if (this.isNew && !this.tenantId) this.tenantId = currentTenantId();
});
schema.pre('insertMany', function (next, docs) {
const tenantId = currentTenantId();
for (const doc of docs) doc.tenantId ??= tenantId;
next();
});
}
Apply it to every tenant-owned model:
// api/src/models.js
import { Schema, model } from 'mongoose';
import { tenantPlugin } from './tenant-plugin.js';
const invoiceSchema = new Schema({
number: { type: String, required: true },
amountCents: { type: Number, required: true, min: 0 },
status: { type: String, enum: ['draft', 'sent', 'paid'], default: 'draft' },
}, { timestamps: true });
invoiceSchema.plugin(tenantPlugin);
invoiceSchema.index({ tenantId: 1, number: 1 }, { unique: true });
invoiceSchema.index({ tenantId: 1, status: 1, createdAt: -1 });
export const Invoice = model('Invoice', invoiceSchema);
Note the compound indexes. In a shared-collection design every index should lead with tenantId; a { number: 1 } unique index would be globally unique across all customers, which is almost never what you want, and a query index without tenantId in front forces the planner to scan across tenants. This is the same prefix rule we cover in index and aggregation tuning for MongoDB 8.
Handlers now look completely ordinary, and are safe anyway:
app.get('/api/invoices', requireAuth, withTenant, async (req, res) => {
res.json(await Invoice.find({ status: 'sent' }).sort({ createdAt: -1 }).limit(50).lean());
});
app.post('/api/invoices', requireAuth, withTenant, async (req, res) => {
const body = InvoiceBody.parse(req.body); // Zod: keeps operator payloads out
res.status(201).json(await Invoice.create(body)); // tenantId injected by the plugin
});
Step 3: close the remaining holes
The plugin covers the common paths. These are the leaks we still look for in code review:
Model.collection.*and raw driver calls bypass Mongoose middleware entirely. Ban them outside migration scripts.populateacross tenants. Arefpopulate runs its own query, and the plugin applies there too — but only if the referenced model also has the plugin. Add it everywhere, including lookup tables you think are shared.$lookupin aggregations. The unshifted$matchscopes the base collection, not the joined one. Add an explicitpipeline: [{ $match: { $expr: ... } }]with the tenant filter inside every$lookup.- Background jobs. Queue workers have no request, so no context. Store
tenantIdon the job payload and wrap the processor inrunInTenant(...)— see background jobs with BullMQ. - Change streams and SSE. Filter the stream server-side by tenant before it reaches a subscriber.
- Caches. Every Redis key must include the tenant id. A cache key of
invoices:sentis a cross-tenant leak waiting to be reported by a customer.
Step 4: prove it with a test
Isolation you have not tested is isolation you do not have. This test fails loudly the day someone adds a model without the plugin:
import test from 'node:test';
import assert from 'node:assert/strict';
import { runInTenant } from '../src/tenant-context.js';
import { Invoice } from '../src/models.js';
test('a tenant cannot read another tenant document', async () => {
const created = await runInTenant({ tenantId: 'tenant-a' }, () =>
Invoice.create({ number: 'INV-1', amountCents: 1000 }));
const asB = await runInTenant({ tenantId: 'tenant-b' }, () =>
Invoice.findById(created._id).lean());
assert.equal(asB, null, 'tenant B must not see tenant A data');
});
test('queries outside a tenant context throw rather than leak', async () => {
await assert.rejects(() => Invoice.find({}), /No tenant in context/);
});
Run it against mongodb-memory-server with the Node 24 native test runner. The second test is the one that earns its keep: a missing context throws instead of quietly returning every tenant's rows.
Step 5: the Angular 22 side
The client's job is narrow: know which tenant is active, send it on every request, and clear cached state when it changes.
// client/src/app/tenant.ts
import { Injectable, signal, computed } from '@angular/core';
import { HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class TenantService {
readonly memberships = signal<{ id: string; name: string; role: string }[]>([]);
readonly activeId = signal<string | null>(localStorage.getItem('tenantId'));
readonly active = computed(() =>
this.memberships().find(m => m.id === this.activeId()) ?? null);
switchTo(id: string) {
localStorage.setItem('tenantId', id);
this.activeId.set(id);
}
}
export const tenantInterceptor: HttpInterceptorFn = (req, next) => {
const id = inject(TenantService).activeId();
return next(id ? req.clone({ setHeaders: { 'X-Tenant-Id': id } }) : req);
};
Because activeId is a signal, a tenant switcher is a computed away, and any resource keyed on it refetches automatically:
invoices = resource({
params: () => ({ tenant: this.tenants.activeId() }),
loader: ({ params }) =>
params.tenant ? firstValueFrom(this.http.get<Invoice[]>('/api/invoices')) : Promise.resolve([]),
});
Two client-side rules: never render anything based on a tenant id the server has not validated, and on switch, drop in-memory caches (and any service worker cache) so stale rows from the previous tenant cannot flash on screen.
Operating it
- Per-tenant metrics. Add
tenant.idas a span attribute in your OpenTelemetry setup so you can see which customer owns the slow requests — see OpenTelemetry tracing for the MEAN stack. - Noisy neighbours. Rate-limit per tenant, not just per IP, and cap expensive aggregations per tenant.
- Data export and deletion. A shared-collection design still owes each customer a clean export and a hard delete. Write those jobs early, with
skipTenantScopeused deliberately and logged. - Migrations. Schema changes now touch every tenant at once. Expand/contract is not optional; see safe MongoDB schema migrations.
Where this usually goes wrong
The failures we get called in to fix are not exotic. A reporting endpoint written in a hurry with an aggregation that skipped the plugin. A cache key without the tenant prefix. A "shared" settings collection that grew per-customer rows. An admin tool that ran skipTenantScope in a request path. Each one is a five-line bug and a customer-trust incident.
If you are designing a multi-tenant MEAN application, or you suspect an existing one is leaking, our MEAN Stack Consulting and Enterprise MEAN Stack App Development teams do exactly this kind of review. Get in touch with a short description of your data model and we will tell you what we would change.