- SaaS
- Architecture
- Security
Tenant isolation in multi-tenant SaaS: three real systems
Tenant isolation patterns from three SaaS systems I worked on: tenant IDs from tokens, query filters, safe auth rollouts, RBAC grant ceilings and plan gating.
8 min read
Most multi-tenant SaaS products start the same way: one database, a tenant column on the important tables, and a login screen. That is a reasonable start. The hard part is making sure one customer can never read or change another customer’s data, including through a forgotten endpoint, an old app version or an admin screen nobody thought about.
I have worked on this problem in three systems on three different stacks. Asaan Optics is my own Node.js and Express SaaS for optical shops. CampusPro is a .NET and PostgreSQL platform for schools, academies and colleges, where I audited and hardened a codebase another developer had built. Track Cue is an in-development Laravel app for snooker clubs that I was assigned to as developer. All three keep their tenants in one shared database. These are the patterns that held up, and the mistakes I now look for first in any SaaS codebase.
Take the tenant from the token, not the request
The most common isolation bug is simple: the server trusts a tenant ID that the client sends. It might be a shop ID in the URL, a tenantId field in the JSON body or a header the mobile app adds. If the server uses that value to scope a query, any signed-in user can change it and see someone else’s records.
In CampusPro the original base, built by another developer, already got this right, and my audit confirmed it. The tenant ID comes only from the signed JWT, never from the client, so there is no request field a caller can use to pick a different tenant.
Asaan Optics had a harder starting point. The mobile and desktop apps had been sending a shop ID with their requests, and installed copies would keep doing so for a long time. Removing the field was not an option, so I made the server treat it as a claim to verify. Once a request carries a verified token (a Firebase ID token or a server-issued session token), every shop ID it names in the query string, body or headers must match the shop in the token. Any mismatch returns 403 with a TENANT_MISMATCH code. The request is rejected rather than quietly corrected, so a client bug surfaces as an error instead of writing to the wrong shop.
Tightening auth without breaking installed apps
For a founder, this is the part that matters commercially. Asaan Optics runs on Android phones and Windows PCs inside shops, and not every shop updates on the day a release goes out. If I had made tokens mandatory for everyone at once, older installs would have stopped syncing.
So I built the change as transitional auth. Tokened requests are checked strictly from day one. Older clients that send no token are still accepted, but each shop has a policy that can be switched to “token required”. Once a shop is running app versions that send tokens, its policy is tightened and untokened requests for that shop are refused. Security improves shop by shop instead of in one risky release. A simplified illustration of the guard (not the production code):
// Simplified illustration of a transitional tenant guard
async function tenantGuard(req, res, next) {
const named = collectShopIds(req); // query, body and headers
const tokenShop = req.auth?.shopId; // set earlier from a verified token
if (!tokenShop) {
// Older app versions that send no token
const shopId = named[0];
if (!shopId || (await shopRequiresToken(shopId))) {
return res.status(401).json({ code: "TOKEN_REQUIRED" });
}
req.shopId = shopId;
return next();
}
if (named.some((id) => id !== tokenShop)) {
return res.status(403).json({ code: "TENANT_MISMATCH" });
}
req.shopId = tokenShop; // handlers only ever read this value
next();
}Two related details made the rollout safer. On the clients, tokens live in secure storage (Windows Credential Manager on desktop). And because the apps work offline, each write waiting in the offline queue is tagged with the shop it was created for, so it carries the right tenant when it is replayed later. The same principle shapes multi-branch organizations, which are still in development: an owner enters a branch through a session the server issues, not by sending a different shop ID.
Let the data layer apply the filter
Taking the tenant from the token only helps if every query uses it. Relying on developers to remember a WHERE clause in every query is how leaks happen, usually in a report or export endpoint written in a hurry.
CampusPro handles this in EF Core with two mechanisms across 22 entity types: global query filters that add the tenant condition to every read, and TenantId stamping on insert, so a new row always belongs to the caller’s tenant. In simplified form:
// Simplified illustration: the tenant comes from the JWT, never the request body
protected override void OnModelCreating(ModelBuilder builder)
{
builder.Entity<Student>().HasQueryFilter(s => s.TenantId == _tenant.Id);
}
public override Task<int> SaveChangesAsync(CancellationToken ct = default)
{
foreach (var entry in ChangeTracker.Entries<ITenantOwned>()
.Where(e => e.State == EntityState.Added))
{
entry.Entity.TenantId = _tenant.Id;
}
return base.SaveChangesAsync(ct);
}Track Cue uses the approach many Laravel apps take: each domain table, such as members, tables, price lists, expenses, games and products, carries a snooker_club_id foreign key with cascade deletes. That gives a clean data model, and removing a club removes its data with it.
Comparing the two taught me that a tenant column makes isolation possible, but it does not enforce it on its own. If each controller query has to add the club filter itself, every new query is a chance to forget. In a new Laravel project I would put the tenant condition in a global scope from the first migration, so the safe query is the default one.
Permissions are part of the tenant boundary
Data filters stop one tenant reading another tenant’s rows. They do nothing to stop a tenant user from promoting themselves to platform administrator. In my CampusPro audit, this was the most serious class of problem: privilege escalation paths that could take a user inside one institution up to platform-level access. I am keeping the details general on purpose.
The security patch I wrote fixed this in three ways:
- A grant ceiling. A user can grant, copy or assign only the permissions they already hold. An institution admin can build any role within their own powers, but never one more powerful than themselves.
- Platform-only permission keys are rejected when attached to institution roles, and ignored at runtime if they ever appear there.
- Super Admin is recognized by a fixed role ID plus platform scope, not by role name. A tenant can name a role anything, so the name proves nothing.
The original base already checked permissions against the database on every request through a [HasPermission] attribute on each endpoint, so a revoked permission does not linger until a token expires. I backed the patch with 39 xUnit integration tests that run against a throwaway PostgreSQL database and cover privilege escalation, grant ceilings, activation, rate limits, logging and data integrity.
Asaan Optics applies the same idea at a smaller scale. Staff accounts get access per screen, enforced on the server by a staff guard, and staff phones never see money figures in home-screen widgets.
Onboarding, secrets and logs cross tenants too
Isolation is not only about queries. In CampusPro, the first sign-in flow allowed an account that had not been activated yet to be claimed by someone else. The patch replaced it with admin-issued one-time activation codes. They are stored only as SHA-256 hashes, valid for 72 hours, single use, bound to one user and one tenant, and revoked after 5 failed attempts. Login is rate-limited per IP and per account, and every failure returns the same generic response.
With the patch, the API refuses to start if the JWT signing key is missing, or, outside development, if it is a placeholder or known-compromised value. One signing key covers every tenant, so a weak key undoes all the isolation work above it. A one-time bootstrap command creates the platform admin and reads the password from an environment variable, never from the command line.
I also reduced request logging to one metadata line per request with a correlation ID. Logging full request bodies copies tokens and personal data from every tenant into files with far weaker access control than the database. An integration test in the patch asserts that secrets never reach the log files.
Plans and limits are tenant state too
Subscription plans decide what a tenant is allowed to do, so those checks deserve the same care as data access. Track Cue defines Basic, Standard and Premium plans with table limits, member limits and a canteen flag. A middleware redirects a club away from premium modules when its subscription is missing or expired, and daily scheduled commands handle expiry and reminder emails, so plan state does not depend on someone remembering to check.
In Asaan Optics the plan gate fails closed: if the server cannot confirm a paid plan, the paid feature stays locked. Plan end dates are only extended from RevenueCat’s own API response, never from a webhook body, and sandbox purchases never count. A billing webhook is just another request from outside, and it should not be able to change a tenant’s entitlements on its own.
Practical takeaways
None of this shows up in a product demo, which is why it is worth checking before the first customer signs up rather than after. The short version:
- Derive the tenant from a verified token or a server-issued session. Treat any tenant ID in a request as a claim, and reject mismatches with a clear error code.
- Put tenant filtering and tenant stamping in the data layer (EF Core global query filters, Laravel global scopes), not in each query.
- If you have installed apps, roll out stricter auth per tenant so you can tighten security without breaking older versions.
- Treat roles as part of the boundary: grant ceilings, platform permissions that tenants can never hold, and admin identity by ID and scope rather than by name.
- Hash one-time codes, refuse weak signing keys at startup, and log metadata instead of request bodies.
- Enforce plans on the server and fail closed.
- Write integration tests that attempt cross-tenant access and privilege escalation against a real database.