- Home
- Case studies
- Multi-tenant SaaS with isolation enforced on every query
Multi-tenant SaaS with isolation enforced on every query
Preventing tenant data leaks by making isolation structural: every query and API path is scoped before business logic can accidentally cross boundaries.
Published Jun 30, 2026Reviewed Jul 2, 2026
The short version
- Problem
- A single missing tenant filter can expose one customer's data to another in a multi-tenant SaaS platform.
- Constraint
- The platform still needed roles, audit trails, money handling, and workflows without asking every feature author to remember every guard.
- System built
- A type-safe full-stack boundary with scoped data access, API-level permission checks, role hierarchy, audit trails, and explicit approvals.
- Result
- Tenant isolation became the default path for new features rather than a convention that could be forgotten under delivery pressure.
- Where this applies
- Relevant for internal tools and SaaS products where growth adds tenants, roles, and sensitive data faster than ad hoc checks can keep up.
- Stack
- tRPC · Prisma · Postgres · Next.js
Why this matters
If your team is planning a system with the same failure modes, this is the level of thinking a first call starts at.
Overview
The constraint above is the entire brief: a multi-tenant SaaS platform needs everything a mature product needs — a role hierarchy, an audit trail that would hold up to a regulator, money handled correctly across currencies, and workflows that gate the actions that matter most — without asking every engineer who touches a query to remember, unprompted, which lines of their code are also a security boundary. None of these requirements is exotic on its own. What makes the combination hard is scale: dozens of features, written by different people over years, where a single omission — one query missing a tenant filter, one endpoint missing a permission check — is the entire distance between "isolated" and "breached." The system below doesn't try to make that omission rare through vigilance; it tries to make it structurally hard to write in the first place.
Structural tenant scoping at the data layer
The failure mode in multi-tenant systems is almost always the same shape: a query that reads or writes a row without first confirming that row belongs to the tenant making the request. It doesn't take a malicious actor — a report endpoint under deadline pressure, a background job joining across tables without carrying tenant context, a new list view copied from an old query that forgot the one line that scoped it. Any of these silently returns another tenant's data to the wrong tenant, and because the query still runs and returns a plausible-looking result, nothing looks broken until a customer notices their competitor's invoice in their dashboard.
The fix here isn't a checklist item at code review — it's removing the option to write an unscoped query at all. Every data access goes through a client already bound to a tenant before any query is written against it: the tenant isn't a parameter you remember to pass, it's a value baked into the client at the point where the request context is resolved, so a query written without it simply has no client to run against.
function forTenant(tenantId: TenantId): ScopedClient {
return prisma.$extends({
query: {
$allModels: {
async $allOperations({ args, query }) {
args.where = { ...args.where, tenantId };
return query(args);
},
},
},
});
}
A tenant-scoped client, resolved once per request and threaded through every resolver
Every procedure that touches the database receives this scoped client from the request context rather than importing a global one. There is no unscoped client sitting in module scope for a rushed feature to reach for instead — reaching for the database at all means reaching through the tenant boundary. That's the entire mechanism: not a rule that queries must be scoped, but an API surface where an unscoped query isn't an available shortcut.
Why not rely on code review and developer discipline
The obvious alternative is a coding convention: every query must include a WHERE tenant_id = ? clause, enforced by review and a lint rule where possible.
That works exactly as well as any convention depending on every person remembering it, every time, forever — it works until it doesn't, and the moment it doesn't is silent.
A missing tenant filter doesn't throw an error, and it doesn't fail a test unless someone wrote a test specifically anticipating that omission.
It returns data, correctly formatted, to a screen where nobody is positioned to notice it's the wrong tenant's data — invisible from the inside, catastrophic from the outside, the worst possible combination for something that's supposed to be caught by a human skimming a diff.
Review also degrades exactly when it matters most: under delivery pressure, on the hundredth similar query, written by whoever joined the team most recently and has the least context on why the pattern exists. A structural boundary doesn't get tired or have an off day. It's not that the team doesn't trust its own engineers — no amount of trust changes the fact that the query layer, not a person's memory, is the only place this can be guaranteed.
API-boundary permission checks and the role hierarchy
Tenant scoping answers "whose data is this." It doesn't answer "is this caller allowed to do this." Those are separate questions, and the second is checked at the API boundary — before a request reaches the resolver that would otherwise execute it — against a five-role hierarchy ranging from read-only access up through the ability to manage billing, membership, and tenant-wide settings.
The check is structured the same way the data scoping is: not as something each resolver author remembers to call, but as a middleware step every procedure passes through by construction. A procedure declares the minimum role it requires; the framework resolves the caller's tenant and role from the request once, and refuses to invoke the resolver at all if the caller doesn't meet it.
const requireRole = (minimum: Role) =>
middleware(({ ctx, next }) => {
if (!ctx.tenant || !ctx.role) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
if (roleRank[ctx.role] < roleRank[minimum]) {
throw new TRPCError({ code: "FORBIDDEN" });
}
return next({ ctx });
});
const manageBillingProcedure = protectedProcedure.use(requireRole("admin"));
A role check composed into the procedure definition, not written per-resolver
The order matters as much as the check itself: authorization happens before business logic runs, not as a condition scattered somewhere inside it. A resolver that never receives an unauthorized request can't accidentally leak a side effect — a partial write, a log line, a queued job — before getting around to checking permission; if the caller doesn't clear the bar, the resolver's code never executes at all.
The five-role shape exists because a binary "member or admin" split under-serves a real platform almost immediately: someone needs to see reports without changing billing, someone needs to manage day-to-day settings without removing other members, and someone needs full control without every other role inheriting it by accident. A hierarchy, rather than a flat set of independent permission flags, keeps that reasoning tractable — each role is a strict superset of the one below it, so "can this role do X" is a single comparison instead of a lookup into a matrix that grows more error-prone with every feature added to it.
GDPR-grade audit trails
Isolation and permission checks answer who can see and do what, right now. An audit trail answers who did see and do what, after the fact — not optional once a platform holds personal data on behalf of customers who are themselves subject to data protection law. A tenant administrator, a regulator responding to a complaint, or an operator investigating an incident all need the same underlying record: which actor, under which role, took which action, against which entity, at what time, within which tenant.
That record has to be trustworthy in a way a debug log isn't. It's written at the same boundary that already resolves tenant and role for every request — the same context that scopes a query or checks a permission is exactly what the audit write needs, so the entry is produced as a side effect of the request pipeline rather than a line someone has to remember to add inside a specific resolver. It's append-only rather than editable, since a record that could be quietly altered after the fact defeats the purpose of having one, and it's tenant-scoped like everything else, so producing a complete trail for a single tenant — the exact shape of request a data-subject access request or a customer's internal audit tends to take — is a bounded query rather than a search across the entire platform's history.
None of this substitutes for the isolation and permission layers underneath it — an audit trail tells you what happened, it doesn't prevent anything from happening. Its value is specifically in cases where prevention already failed or is disputed: proving what did and didn't happen, to a standard someone outside the engineering team can rely on.
Multi-currency money without floating point
Money is the one place where a generic numeric type is actively the wrong tool, and the reason is mechanical rather than stylistic. Floating-point numbers represent value in binary fractions, and most decimal amounts — the ordinary kind on every invoice, like 0.10 or 19.99 — don't have an exact binary representation. Each individual rounding error is tiny, but financial arithmetic is rarely one operation: sums across line items, prorations across billing periods, conversions across currencies, and small errors compound across all of it in ways that don't cancel out. An amount off by a fraction of a cent isn't a rounding curiosity when it's someone's invoice, someone's payout, or a reconciliation that has to tie out to the last unit.
The fix is representational rather than a rule to be careful. Amounts are stored and computed as integers in a currency's smallest unit — cents, not dollars — using integer or arbitrary-precision arithmetic rather than native floating-point math, so there is no fractional binary representation to lose precision in the first place. Currency itself is carried as part of the value rather than as separate, easy-to-drop metadata next to a bare number, so an operation that would mix two currencies — adding an amount in one currency to an amount in another without an explicit, deliberate conversion step — isn't just a bug to catch in review, it's a type the code has no way to construct by accident.
type Money = {
amountMinorUnits: bigint;
currency: CurrencyCode;
};
Currency travels with the amount; there is no bare number to misinterpret
This doesn't make currency conversion itself free of judgment — a conversion still needs a rate, a rounding rule, and a decision about which side of a transaction absorbs the remainder. What it removes is the far more common failure: silent precision loss from using the wrong number type for a domain where "close enough" was never actually close enough.
Explicit approval workflows for sensitive actions
A role check answers whether a caller is technically permitted to perform a class of action. It doesn't answer whether this instance of that action should execute unilaterally, right now, on one person's judgment. For a subset of actions — ones with consequences that are expensive or impossible to undo, like a large refund, a bulk export of personal data, or a change to a tenant's billing arrangement — the platform inserts a second gate on top of the permission check: the action is requested, not executed, and needs approval from someone else before it takes effect.
This is a deliberate, narrow use of friction. Most actions are exactly as fast as the role hierarchy allows, because most don't need a second opinion — adding friction everywhere would just teach people to click through it unread. The approval gate is reserved for actions where a second set of eyes catches something a single actor might not: a refund large enough to be a mistake, an export broad enough to warrant asking why, a billing change with consequences beyond the person requesting it. Separating "who can request this" from "who can approve it" also means a single compromised or careless account can't unilaterally execute the platform's most consequential actions — the same principle behind the role hierarchy, applied to specific high-stakes actions rather than whole categories of endpoint.
Tradeoffs and limitations
None of this is a substitute for correctness elsewhere, and being honest about what it doesn't guarantee is part of the design:
- Structural scoping only protects paths that actually go through the scoped client. A raw query, a migration script, or an admin tool that reaches the database directly reopens the hole this was built to close — the boundary is only as complete as the discipline routing every access path through it.
- A five-role hierarchy has to be revisited as the product grows. Too coarse, and new features get bolted onto a role that doesn't quite fit or bypass the hierarchy with a one-off check that erodes the model it was meant to replace.
- Audit trails add write overhead to every mutation and grow storage indefinitely — and because the trail itself often contains personal data, it inherits the same data-protection obligations as the data it describes.
- Approval workflows add latency to sensitive actions on purpose. That's correct friction for what they're meant to gate and wrong friction for anything misclassified into that set — over-broad gating trains people to route around it.
- Type safety at the API and data-access boundary proves the shapes are correct at compile time, not that isolation and permission behavior is correct at runtime — a query that type-checks can still be logically wrong, which is why this boundary needs its own tests, not just a satisfied compiler.
- Correct money representation removes floating-point error; it doesn't remove judgment calls elsewhere in the domain — exchange rates, rounding rules, and who absorbs a remainder are business decisions the type system can't make.
References and further reading
Related case studies
Systems that share a constraint with this one — retries, isolation, cost, recovery.
Idempotent webhook ingestion at scale
Data-loss risk moved from a quarterly investigation to continuous checks that detect gaps, repair them, and expose operational health.
Go · Postgres (SKIP LOCKED, matviews) · HMAC · advisory locksA durable job queue that replaced a wall of no-code automations
Failures became queryable rows, retries became safe by construction, and new automations could be tested against live traffic before acting.
Go · Postgres leases · gocron · bearer + HMAC auth