Skip to content
← All articles

Sep 13, 2026
2 min read

  • Nest.js
  • PostgreSQL
  • Multi-tenant SaaS
  • Security
  • Backend

Hela, Nunua and Safirisha all let one account run several businesses. The hard part is not the feature — it is making sure company A can never read company B.

Several products I've built share the same shape: one user, several companies (a shop and a bar, two transport agencies, a school and its annex). Each company must see only its own data, while the user switches between them in one click.

There are three classic ways to isolate tenants. Here is why I usually pick the simplest — and what makes it safe.

The three options

StrategyIsolationCost to runGood for
Database per tenantStrongestHighFew large clients, strict compliance
Schema per tenantStrongMediumDozens of tenants
Shared tables + tenant_idDepends on youLowMany small tenants

For SMEs in the region — many small companies, little budget — shared tables with a company_id column win. The risk is obvious: forget one WHERE company_id = … and you leak data.

Make the tenant impossible to forget

The fix is to never let a developer write that filter by hand.

1. Resolve the tenant once, per request. A Nest.js guard reads the active company from the session, checks the user really belongs to it, and stores it in request-scoped context:

@Injectable()
export class TenantGuard implements CanActivate {
  constructor(private readonly memberships: MembershipService) {}

  async canActivate(ctx: ExecutionContext) {
    const req = ctx.switchToHttp().getRequest();
    const companyId = req.headers['x-company-id'];
    const ok = await this.memberships.has(req.user.id, companyId);
    if (!ok) throw new ForbiddenException();
    req.companyId = companyId;
    return true;
  }
}

2. Scope every query in the repository layer, not in controllers. A base repository adds the company_id condition to every read and sets it on every insert. Feature code simply cannot query "all invoices".

3. Let PostgreSQL enforce it too. Row-level security is the safety net if the application ever gets it wrong:

ALTER TABLE invoice ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON invoice
  USING (company_id = current_setting('app.company_id')::uuid);

The connection sets app.company_id at the start of each transaction. Even a raw SQL query written in a hurry stays inside its tenant.

Index for the tenant first

Almost every query filters by company, so composite indexes start with it:

CREATE INDEX ON invoice (company_id, created_at DESC);

Test the leak, not just the feature

My favourite test in these projects is boring and essential: create two companies, write data in A, call every list endpoint as a member of B, and assert it returns nothing from A. It catches regressions that code review misses.

What about shared data?

Some things legitimately cross tenants — a supplier used by both your shop and your bar, for example. Model that explicitly (a supplier owned by the user, linked to companies) instead of loosening the tenant rule.

Multi-tenancy is mostly discipline. Put the tenant check in one place, back it with the database, and test that isolation holds. Then the feature itself — switching companies in one click — becomes the easy part.