Technical Article
Node.js Multi-Tenant SaaS: AsyncLocalStorage and Row-Level Security
The four tenancy rules I run on Laravel, translated to Node.js: AsyncLocalStorage for the tenant, row-level security that fails closed, and tenant-aware jobs.
A multi-tenant SaaS in Node.js needs the same four pieces as one in any other stack: somewhere to hold the current tenant, a way to find it, a rule that enforces it on every query, and a way to carry it into background jobs. What changes in Node.js is where each piece lives. There is no per-request container, so the tenant travels in AsyncLocalStorage. And because Node.js data layers vary so much, the enforcement I trust most sits in PostgreSQL itself, as row-level security. My own multi-tenant platforms run on Laravel, and the Next.js front-end I work on is a client of one of them, so this article is the translation: each rule I run in production, and the Node.js mechanism I would use for it. The order those decisions come in is covered in SaaS platform architecture: the decisions that come first.
Four pieces of a multi-tenant Node.js app
The pieces and their rules are the ones this website’s Laravel platform runs on, described in Laravel multi-tenancy. The table shows where each one goes in a Node.js service using a shared PostgreSQL database with a tenant_id column.
| Piece | Rule | Node.js mechanism |
|---|---|---|
| Tenant context | One place holds the tenant; asking outside a tenant throws | AsyncLocalStorage from node:async_hooks |
| Resolution | Unknown host is 404, never a default tenant | The first middleware in Express, Fastify or NestJS |
| Enforcement | A missing tenant fails closed | PostgreSQL row-level security, plus an ORM filter if you want one |
| Propagation | Jobs run under the tenant that queued them | Tenant id in the job data; the worker runs inside the storage |
The tenant context: AsyncLocalStorage
Node.js serves every request from the same process and the same module instances, so a module-level currentTenant variable would be shared by all concurrent requests. AsyncLocalStorage solves that: a value stored with run() is visible to everything that callback starts, through await, timers and promises, and invisible to other requests.
Give it the same three methods as the Laravel version: one that returns the tenant or throws, one that runs a callback under a tenant, and nothing that lets code set a tenant for “the rest of the process”. Avoid enterWith() for this reason: it changes the store for the rest of the current execution, which is the global variable problem again.
import { AsyncLocalStorage } from 'node:async_hooks';
const storage = new AsyncLocalStorage<{ tenantId: number }>();
export function requireTenant(): number {
const store = storage.getStore();
if (!store) {
throw new Error('No tenant is bound to the current context.');
}
return store.tenantId;
}
export function runAsTenant<T>(tenantId: number, fn: () => T): T {
return storage.run({ tenantId }, fn);
}The storage follows the async chain, not the call stack, and a few callback-based libraries lose it, typically ones that keep their own pool of callbacks. Node’s documentation covers the fix: bind the callback with AsyncResource.bind(). A test that reads requireTenant() inside each library callback you rely on catches this early.
Resolution: first middleware, no fallback
Resolve the tenant before any route runs and wrap the rest of the request in runAsTenant(). The rules are the same as on my Laravel platform, where each one has a test: an unknown host returns 404, a suspended tenant returns 503, and nothing falls back to a default tenant.
app.use(async (req, res, next) => {
const tenant = await tenants.findByHost(req.hostname);
if (!tenant) return res.status(404).end();
if (tenant.suspended) return res.status(503).end();
runAsTenant(tenant.id, () => next());
});Behind a load balancer, req.hostname is only the visitor’s host if Express is told to trust the proxy. Cache the host lookup by id only, not as a whole tenant object, so an edited or suspended tenant is not served from a stale copy.
In NestJS the same middleware works. Nest’s alternative, request-scoped providers, creates the provider and everything that depends on it again for each request, which the Nest documentation warns has a performance cost; the storage approach avoids that.
Enforcement: row-level security in PostgreSQL
In Laravel I enforce the tenant in one Eloquent trait, because every tenant-owned query goes through Eloquent. Node.js services are rarely that uniform: an ORM for most queries, a query builder for reports, raw SQL for the odd migration. A filter in one of them doesn’t protect the others. PostgreSQL’s row-level security does, because the database applies the policy to every query on the table, whatever sent it.
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
ALTER TABLE invoices FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON invoices
USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::bigint)
WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::bigint);Each line is there for a reason. FORCE applies the policy to the table’s owner too, who would otherwise bypass it. current_setting(..., true) returns nothing instead of an error when the setting is missing, and NULLIF turns an empty setting into NULL, so a query with no tenant set matches no rows. That is fail closed, the same as the Laravel scope that throws. WITH CHECK stops inserts and updates that would write a row for another tenant. And the application must connect as a role that is not a superuser and does not have BYPASSRLS, because those skip every policy.
Set the tenant per transaction, not per connection
Connection pools are where row-level security goes wrong. A plain SET app.tenant_id lasts for the connection’s session, and a pooled connection is handed to the next request, which then inherits the previous tenant. Use set_config() with its third argument true, which lasts only until the transaction ends, and send it in the same transaction as the query.
Prisma publishes an extension that does exactly this for every query. Simplified, with the tenant read from the context:
export const db = prisma.$extends({
query: {
$allModels: {
async $allOperations({ args, query }) {
const tenantId = requireTenant();
const [, result] = await prisma.$transaction([
prisma.$executeRaw`SELECT set_config('app.tenant_id', ${tenantId.toString()}, true)`,
query(args),
]);
return result;
},
},
},
});requireTenant() throws before any SQL is sent, and if code reaches the database some other way, the policy returns nothing. Two layers, both failing closed. Platform-level code that needs every tenant uses a separate database role with BYPASSRLS and a separate client, so the opt-out is as visible in Node.js as acrossTenants() is in my Laravel code.
Background jobs: carry the id, run inside the storage
A queued job runs in a worker with no request and therefore no tenant. Put the tenant id in the job’s data when it is queued, and have the worker run the handler inside runAsTenant(). A job queued without a tenant then runs without one, and the first query it makes fails closed.
await invoices.add('send', { tenantId: requireTenant(), invoiceId });
new Worker('invoices', async (job) =>
runAsTenant(job.data.tenantId, () => sendInvoice(job.data.invoiceId)),
{ connection },
);The same rule covers everything else that outlives a request: cache keys, file paths and log context should all read the tenant from the one context, never from a parameter someone might forget to pass.
The tests to write first
- Calling
requireTenant()outsiderunAsTenant()throws. - Querying a tenant-owned table with no tenant set returns no rows, through the ORM and through raw SQL.
- Inserting a row for another tenant fails the policy check.
- Two concurrent requests for different tenants each see only their own rows.
- A pooled connection used by tenant A and then tenant B shows tenant B only B’s rows.
- A job queued under a tenant runs under that tenant.
The fourth and fifth tests are specific to Node.js and pooling, and they are the ones that catch the leaks that look fine with one developer and one tenant.
Where to go next
Roles and record visibility inside a tenant are covered in multi-tenant access control. For an outside review of a Node.js platform’s tenancy and data layer, see SaaS architecture.
References
Evidence and further reading
All insights →Facing a similar problem?
Platform & Integration Review
For SaaS founders and engineering leads whose platform is getting slower and riskier to change.
Request an assessmentLet's build what's next
Have a complex system that needs to be built right?
Whether you are starting from an idea, replacing an existing platform, or scaling a system, let's talk.
Better Technology.
Brighter Possibilities.

