Organization and Multi-Tenant Authentication
- multitenancy
- saas
- organizations
- nestjs
- postgresql
- authentication
- authorization
- backend
In the previous article we settled how a session gets created, stored, and refreshed. That covers a single user proving who they are. It says nothing about which company's data they should be allowed to see once they're in. For a SaaS product, that second question is usually the harder one, and it's the subject of this article.
This is part of the Full Stack SaaS Masterclass, a build-it-for-real series that started with choosing the tech stack and is now working through the authentication module piece by piece. Organizations sit at the center of almost every SaaS decision from here on: billing, permissions, feature flags, even which database rows a query is allowed to touch.
Get the organization model wrong early and you end up retrofitting tenancy into a schema and an API surface that never expected it. That kind of migration is painful to do with live customer data. So it's worth spending real time on the shape of it now, before there's much to migrate.
Why "organization" needs to be a first-class concept
Most B2C apps have exactly one relationship that matters: user to their own data. A B2B SaaS has three: user to organization, organization to its data, and (often) user to multiple organizations at once. If your schema and your auth layer only model the first relationship, every feature after this one inherits the gap.
The organization is the actual tenant boundary. A Project doesn't belong to a User; it belongs to an Organization, and a user can act on it only through some membership in that organization. This distinction matters the moment you support:
- A user who works with two different companies through the same email address.
- Seat-based billing, where the unit of purchase is the organization, not the person.
- Data residency or export requirements scoped to a company, not an individual.
- An admin removing a teammate without losing that teammate's contributions to shared data.
None of those work cleanly if user_id is the only foreign key on your business tables. organization_id needs to be there too, and it needs to be there from the first migration, not added later as an afterthought column that half your queries forget to filter on.
Modeling organizations and membership
The core of this is a three-table shape: organizations, users, and a membership table that joins them with a role attached. Keeping membership as its own table, rather than a single organization_id column on users, is what lets a user belong to more than one organization.
create table organizations (
id uuid primary key default gen_random_uuid(),
name text not null,
slug text not null unique,
created_at timestamptz not null default now()
);
create table users (
id uuid primary key default gen_random_uuid(),
email text not null unique,
password_hash text not null,
created_at timestamptz not null default now()
);
create table memberships (
id uuid primary key default gen_random_uuid(),
organization_id uuid not null references organizations(id) on delete cascade,
user_id uuid not null references users(id) on delete cascade,
role text not null check (role in ('owner', 'admin', 'member')),
created_at timestamptz not null default now(),
unique (organization_id, user_id)
);
create index idx_memberships_user on memberships (user_id);
create index idx_memberships_org on memberships (organization_id);
Every tenant-scoped table in the rest of the schema (projects, invoices, documents, whatever your product deals in) should carry an organization_id column and an index on it. That column is not optional, and it's not something you add "when you need it." Every query against those tables filters on it, so it belongs in the design from day one.
The unique (organization_id, user_id) constraint keeps a user from having two memberships in the same org, which would otherwise create ambiguity about which role applies.
Getting tenant context into a request
Once a user can belong to multiple organizations, the API needs a reliable way to know which organization the current request is acting for. There are three common approaches, and they're not mutually exclusive.
Subdomain per organization (acme.yourapp.com). Clean for the user, since the URL itself communicates context, and it makes it hard to accidentally leak data across a browser tab left open on the wrong org. The cost is real infrastructure: wildcard DNS, wildcard TLS certificates, and routing logic that resolves the subdomain to an organization on every request.
Active organization encoded in the session or JWT. The user picks an active org (often via a switcher in the UI), the server stores that choice, and every subsequent request is scoped to it without the client having to pass anything extra. This is the simplest to build and is what I'd default to unless the product specifically needs the subdomain-per-tenant feel.
Explicit organization ID per request (a header or a path segment like /orgs/:orgId/projects). Most flexible for API clients hitting multiple orgs in parallel, but it pushes the isolation check entirely onto the server: you must verify, on every single request, that the caller actually has a membership in the org they're claiming.
Whichever you pick, the tenant ID has to end up somewhere the server can trust without an extra database round trip on every request. Embedding it in the access token, alongside the active membership's role, is the cheapest way to get that.
// auth/jwt-payload.interface.ts
export interface JwtPayload {
sub: string; // user id
orgId: string; // active organization
role: 'owner' | 'admin' | 'member';
iat: number;
exp: number;
}
Enforcing isolation at the guard, not the controller
The temptation is to check organization_id inside each service method or repository call. That works right up until someone adds a new endpoint and forgets. The safer pattern is a guard that resolves and attaches tenant context before the controller ever runs, so there's a single place the check can't be skipped.
// auth/tenant.guard.ts
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common';
import { MembershipsService } from '../memberships/memberships.service';
@Injectable()
export class TenantGuard implements CanActivate {
constructor(private readonly memberships: MembershipsService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const userId: string = request.user.sub;
const orgId: string = request.user.orgId;
const membership = await this.memberships.findActive(userId, orgId);
if (!membership) {
throw new ForbiddenException('No active membership in this organization');
}
request.membership = membership;
return true;
}
}
// projects/projects.controller.ts
import { Controller, Get, UseGuards } from '@nestjs/common';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { TenantGuard } from '../auth/tenant.guard';
import { CurrentOrg } from '../auth/current-org.decorator';
@Controller('projects')
@UseGuards(JwtAuthGuard, TenantGuard)
export class ProjectsController {
@Get()
findAll(@CurrentOrg() orgId: string) {
return this.projectsService.findAllForOrg(orgId);
}
}
Two things matter here beyond the code itself. First, the guard re-checks membership against the database rather than trusting the JWT claim in isolation. Tokens are stale by design, which is the whole tradeoff we covered with expiry in the session-management article. So if someone was removed from an org two minutes ago, a still-valid token shouldn't grant them anything. Second, every service method should take orgId as an explicit parameter and use it in the WHERE clause, never rely on the guard alone as the only line of defense. Defense in depth here is cheap. The failure mode without it, one query returning another company's data, is the worst kind of incident a SaaS can have.
Switching organizations and stale claims
If a user belongs to multiple organizations, at some point they'll switch the active one in the UI. That switch mutates session state rather than just toggling something on screen, so it needs to reissue credentials instead of updating local storage and calling it done.
// auth/auth.controller.ts
@Post('switch-organization')
@UseGuards(JwtAuthGuard)
async switchOrganization(
@CurrentUser() userId: string,
@Body() dto: SwitchOrgDto,
) {
const membership = await this.memberships.findActive(userId, dto.organizationId);
if (!membership) {
throw new ForbiddenException('Not a member of this organization');
}
return this.authService.issueTokens({
sub: userId,
orgId: membership.organizationId,
role: membership.role,
});
}
This same reissue path is where role changes need to propagate. If an admin downgrades a user from admin to member mid-session, that user's existing access token still carries the old role until it expires or gets refreshed. Short-lived access tokens (the pattern from the refresh-token article) bound the blast radius of that staleness to minutes rather than hours. If your product needs role changes to take effect instantly, that's a case for checking role from the database on sensitive actions rather than trusting the token claim, which trades a bit of latency for correctness.
Production pitfalls worth naming
A few mistakes show up repeatedly in multi-tenant systems, and they're worth checking for explicitly rather than assuming your test suite would catch them.
Missing a WHERE organization_id = $1 on a query is the classic one, and it's rarely caught by manual testing because the demo account usually only belongs to one org. An integration test that creates two organizations and asserts one can never see the other's data is worth writing early and keeping in CI permanently.
Trusting a client-supplied orgId (from a header or query param) without verifying membership is the same bug wearing a different hat. The server must always independently confirm the caller's membership. It can never take the client's word for which org it's acting as.
Deleting a user without deciding what happens to their memberships and the data they created can leave orphaned rows or, worse, silently reassign ownership in a way nobody intended. Decide this deliberately: cascade the membership, but keep authored records attached to the user ID (with a "deactivated" flag) so history stays intact.
Forgetting to invalidate cached permission checks after a role change is easy to introduce once you add a Redis layer in front of membership lookups for performance. If you cache hasPermission(userId, orgId), the cache key needs to be invalidated the moment a membership or role changes, not just left to expire on a TTL.
Key takeaways
- Model organizations and membership as first-class entities from the first migration; retrofitting tenancy into an existing schema is expensive and risky.
- A separate membership table (not a single column on `users`) is what allows a user to belong to more than one organization with different roles.
- Encode the active organization and role in the access token, but re-verify membership against the database at the guard layer rather than trusting the claim alone.
- Enforce tenant isolation with a guard plus explicit `organization_id` filtering in every service query; treat this as defense in depth, not either-or.
- Switching organizations or changing a role should reissue credentials, not just update client-side state.
- Write and keep a cross-tenant isolation test in CI; it catches the single most damaging class of bug in a multi-tenant system.
Frequently asked questions
What's the difference between multi-tenancy and role-based access control?
Multi-tenancy is about which organization's data a request can see at all. RBAC is about what a member of that organization is allowed to do once they're inside it. You need both: tenant isolation as the outer boundary, roles and permissions as the inner one.
Should each tenant get its own database or schema, or should everyone share one set of tables?
Shared tables with an `organization_id` column is the right default for most SaaS products because it's simpler to operate and migrate. Separate schemas or databases per tenant make sense when you have strict compliance requirements or a small number of very large customers, but they multiply your migration and operational overhead.
How do I let a user belong to multiple organizations without confusing sessions?
Keep an explicit "active organization" concept in the token or session, expose an endpoint to switch it, and reissue credentials on switch rather than mutating client-side state alone. Every server-side check should read the active org from that trusted source, not from a client-supplied parameter.
Is it safe to trust the organization ID stored in a JWT?
Trust it for routing and convenience, but re-verify actual membership against the database at the guard layer for anything sensitive. JWTs are stale by design once issued, so a claim alone isn't proof of current access.
What happens to an organization's data when the last owner account is deleted?
Decide this before it happens in production. A common approach is to require at least one owner per organization at all times (block removing the last one) and handle full organization deletion as an explicit, separate, audited action rather than a side effect of user deletion.
Further reading
Explore more on

About the author
I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.