Implementing Multi‑Tenancy Scoping Across the VS API – Phase 4 Code Walkthrough
TL;DR: I finished Phase 4 of the multi‑tenancy migration by centralizing organization scoping in a guard and a service, then wiring every controller and repository to use it. The change eliminates accidental data leaks and makes future tenant‑specific features a one‑line addition.
The Problem
Our VS API started as a single‑tenant monolith. When we began splitting the product for multiple organizations, the first three phases added an organization_id column to a handful of tables and patched a few endpoints. By the end of Phase 3, the API still had silent leaks: some GET /stats and POST /share-links routes ignored the tenant context, returning data from other customers. The symptom showed up in the logs as:
ERROR [TenantGuard] Missing organizationId in request context
and, more importantly, a client reported seeing another company’s construction projects in their dashboard. The root cause was that tenancy checks were scattered, duplicated, and easy to forget.
What I Tried First
My first instinct was to sprinkle organization_id checks directly inside each controller method:
// apps/api/src/construction/construction.controller.ts (initial attempt)
@Get()
async findAll(@Req() req: Request) {
const orgId = req.headers['x-org-id'];
return this.constructionService.findAll(orgId);
}
I added the header extraction to a few controllers, updated the service signatures, and ran the test suite. It worked for those endpoints, but I quickly ran into two problems:
-
Missing in many places – I forgot to add the guard to
stats.controller.tsandshare-links.controller.ts. The API still exposed cross‑tenant data. -
Boilerplate explosion – Every method now required a
orgIdargument, making the code noisy and error‑prone.
I also tried a global interceptor that injected organizationId into the request object based on the JWT payload. The interceptor ran before routing, but it broke pagination because the interceptor mutated the query parameters after NestJS had already parsed them, leading to TypeError: Cannot read property 'skip' of undefined.
Both approaches failed to give me a reliable, maintainable solution.
The Implementation
1. Central guard – OrganizationGuard
I created a dedicated guard that extracts the tenant identifier from the JWT (or the x-org-id header for internal services) and stores it in the request’s locals object. The guard is applied globally in the AppModule, guaranteeing that every request carries a validated organizationId.
// apps/api/src/common/guards/organization.guard.ts
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
@Injectable()
export class OrganizationGuard implements CanActivate {
constructor(private readonly jwtService: JwtService) {}
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
const authHeader = request.headers.authorization;
if (!authHeader) {
throw new UnauthorizedException('Missing Authorization header');
}
const token = authHeader.split(' ')[1];
const payload = this.jwtService.verify(token);
const orgId = payload.organizationId ?? request.headers['x-org-id'];
if (!orgId) {
throw new UnauthorizedException('Organization ID not found in token or header');
}
// Store for downstream services
request.locals = { ...request.locals, organizationId: orgId };
return true;
}
}
Why a guard?
Guards run before route handlers, can abort the request early, and integrate cleanly with NestJS’s built‑in authentication flow. By placing the tenant extraction here, we avoid repeating the same logic in every controller.
2. Scoping service – OrganizationScopeService
To keep the guard thin, I introduced a service that provides the current tenant ID and a helper to build scoped where clauses for TypeORM queries.
// apps/api/src/common/services/organization-scope.service.ts
import { Injectable, Scope } from '@nestjs/common';
import { Request } from 'express';
import { REQUEST } from '@nestjs/core';
@Injectable({ scope: Scope.REQUEST })
export class OrganizationScopeService {
constructor(@Inject(REQUEST) private readonly request: Request) {}
get organizationId(): string {
return this.request.locals?.organizationId;
}
/** Returns a TypeORM-compatible filter object */
scope<T extends { organizationId?: string }>(extra?: Partial<T>) {
return { organizationId: this.organizationId, ...extra } as T;
}
}
Because the service is request‑scoped, each HTTP request gets its own instance, and we can safely read request.locals without worrying about cross‑request contamination.
3. Updating Controllers
All controllers now inject OrganizationScopeService and delegate tenant filtering to the service. Below is the final version of the construction controller that was added in Phase 4 (see the diff entry apps/api/src/construction/construction.controller.ts).
ts
// apps/api/src/construction/construction.controller.ts
import { Controller, Get, Post, Body, Param } from '@nestjs/common';
import { ConstructionService } from './construction.service';
import { OrganizationScopeService } from '../../common/services/organization-scope.service
---
*Part of my [Build in Public](https://dev.to/zaerohell) series — sharing the real process of building SaaS projects from Playa del Carmen, México.*
*Repo: `zaerohell/content-automation` · 2026-09-11*
\#playadev #buildinpublic
Top comments (0)