14 September 2026 · 7 min read
Multi-tenant SaaS leaks data one missing WHERE clause at a time. In the retail POS SaaS I made that class of bug throw before the SQL is built, without hiding the filter from the people reading the code.
The retail ERP/POS SaaS runs many shops on one database schema. It's the right call for a product aimed at small shops: one migration, cheap cross-tenant reporting for the platform, simple operations. It also means the single most likely security bug in the codebase is a query that forgets to filter by organizationId and returns another shop's sales.
Convention doesn't fix that. Code review doesn't reliably fix that either; there are hundreds of queries and the missing clause looks exactly like a correct one. I wanted the mistake to be impossible to ship, and I wanted the code to stay readable for the next engineer. Those two goals pull in opposite directions, and the design is mostly about resolving that.
Where the tenant comes from
First principle: the organization is read from the signed access token and from nowhere else. Not a request body, not an X-Org header, not a query parameter. Those are all under the client's control. The guard then re-resolves the caller's membership from the database on every request, so a deactivated employee or a suspended shop loses access immediately rather than at token expiry. The result is an AuthContext that controllers derive everything from.
/**
* The authenticated caller, resolved server-side from the access token and the
* database. Controllers must derive organizationId/branchId from here, never
* from the request body or a client-supplied header.
*/
export interface AuthContext {
userId: string;
organizationId: string;
membershipId: string;
role: Role;
branchId: string;
allowedBranchIds: string[];
canAccessAllBranches: boolean;
terminalId: string | null; // a paired till fixes the branch
permissions: Set<Permission>;
}There is a second axis inside a tenant: branches. A branch manager who calls a list endpoint without a branchId should see their branches, not the whole organization. That used to be a bug (no branchId meant everything), so it became a helper that every branch-scoped query goes through.
export function branchScope(ctx: AuthContext, requested?: string | null) {
if (requested) return { branchId: requested };
if (ctx.canAccessAllBranches) return {};
return { branchId: { in: ctx.allowedBranchIds } };
}Services still say it out loud
The tempting design is to inject the tenant filter automatically so services never mention it. I didn't do that. Services pass organizationId into every query explicitly, because the code a reviewer reads should show the filter, not trust that some middleware added it. Readability is a security property.
…and the ORM refuses when they don't
The backstop is a Prisma client extension that wraps every operation on the 21 models that belong to a tenant. A findMany, update, delete, count or aggregate that arrives without organizationId in its where clause throws before any SQL is generated. The check recurses through AND and OR, so wrapping the filter in a compound clause still counts. Writes must set organizationId on every row, or connect the organization relation.
export const TENANT_SCOPED_MODELS = new Set([
'Branch', 'Membership', 'Product', 'BranchInventory', 'StockMovement',
'Customer', 'Sale', 'SaleItem', 'Payment', 'Refund', 'Expense', /* … */
]);
export function createTenantGuardExtension() {
return Prisma.defineExtension({
name: 'tenant-guard',
query: {
$allModels: {
async $allOperations({ model, operation, args, query }) {
if (!model || !TENANT_SCOPED_MODELS.has(model)) return query(args);
if (WHERE_OPERATIONS.has(operation) && !whereHasOrganization(args.where)) {
throw new AppException(
ErrorCode.TENANT_SCOPE_MISSING,
`Refused an unscoped ${operation} on ${model}: queries must filter by organizationId.`,
);
}
if (DATA_OPERATIONS.has(operation) && !dataHasOrganization(args.data)) {
throw new AppException(ErrorCode.TENANT_SCOPE_MISSING, /* … */);
}
return query(args);
},
},
},
});
}It is a safety net, not the mechanism: services still pass organizationId explicitly, which keeps call sites readable and auditable.
That comment is in the file, and it's the whole philosophy. The extension is not how tenancy works. It is what catches the day someone gets it wrong: in development and in the e2e suite, loudly, with a stable error code, instead of in production, silently, with someone else's data.
The escape hatch is named
Some work is legitimately cross-tenant: logging in by email before you know the organization, platform-admin aggregates, migrations, seeds. That goes through PrismaService.unscoped, the raw client, with a name that makes it obvious in review. If you see unscoped in a diff, you ask why.
What else is in the same layer
- Guards check permissions, not roles. Roles are named sets in a catalogue, so a shop can change what a manager can do without a deploy.
- Passwords are Argon2id. Refresh tokens are stored as SHA-256 digests, rotated on use, and reusing an old one revokes the whole token family.
- One response envelope, stable error codes, no stack traces in responses. Swagger is served in non-production only.
- A security.md that lists what is implemented and what is deliberately not claimed. The second list is the more useful one.
None of this is exotic. It's the combination that matters: identity from the token only, membership re-checked per request, explicit filters at call sites, and an ORM-level refusal when a filter is missing. Four layers, each cheap, and the failure mode of each one is caught by the next.