Multi-tenancy is usually introduced as a data modelling decision: add a tenant identifier to every table and put it in a where clause. That is the smallest part of the problem. The decisions that determine whether a multi-tenant system is safe are about authorization, and they are cheaper to make before the first tenant signs up, because retrofitting means auditing every query, job and integration that assumed a single owner.
This article covers the separation of global identity, tenant membership and tenant-scoped grants, where authorization decisions belong, what each isolation model buys, where tenant scope leaks when nobody is watching the request path, and how to make the isolation claim testable.
Why a single role column breaks down
The usual first cut is a user table with a role column and a tenant column. It works while there is one tenant, one kind of administrator and one role that means anything. All three stop being true early.
First, the number of tenants. Once several organisations share the system, "is this user an admin" becomes a question about which organisation you are asking about.
Second, the number of roles that carry authority. A platform operator who provisions tenants, a tenant administrator who manages their own instructors and billing, an instructor who can edit course content but not publish it, a learner, and a read-only auditor are five different kinds of authority. A flat scale forces a choice between over-granting the lower roles and inventing role names with prefixes that encode the tenant distinction inside a string, where it cannot be enforced.
Third, time. Roles change. An instructor becomes a tenant administrator; somebody leaves. A column holds a current value with no history, and revocation is a single overwrite any concurrent write can race.
The deeper problem is that a role column answers a question about a user, when the real question is about an action on a resource inside a tenant. The additive data model that shift depends on is covered in how to design scalable web applications for long-term growth.
Global identity, tenant membership, tenant-scoped grants
Global identity: who this person is
An account row, unique per person or per service identity, holding authentication material and profile data. It knows nothing about tenants, which is what makes a shared sign-in possible.
Tenant membership: which tenant is this person in, and how
A join row between an identity and a tenant, with the state of that relationship: invited, active, suspended. Membership answers whether the person is present in the tenant at all, not what they may do inside it.
Tenant-scoped grants: what may this person do here
Grants are the authority layer, and every grant is scoped to one tenant. A role assignment says "this person is an instructor in this tenant" and is simple to store. A capability grant says "this person may publish courses inside this tenant" and is more verbose, but it puts the meaning in the code that checks it rather than in the role name.
A workable middle ground is to store role assignments and resolve them through a role definition table, so role names stay administrative while the capabilities behind a role are data.
A data model that keeps the layers separate
Four tables, and the shape matters more than the naming. Every grant row carries its own tenant identifier.
- identity: an id, a unique email, a display name and a created timestamp. Nothing about tenants appears in this table.
- tenant: an id, a unique slug, a name and a status. One row per customer organisation.
- tenant_membership: a tenant reference, an identity reference, a state of invited, active or suspended, who invited them, and a created timestamp. The primary key is the pair, so one identity holds one membership per tenant.
- tenant_grant: an id, a tenant reference, an identity reference, a capability such as publish-course or enrol-learner, an optional scope narrowing it to one course or campus, who granted it and when, and a revoked_at column.
Three properties do the real work. A uniqueness constraint on the triple of tenant, identity and capability makes a duplicate grant impossible rather than merely unusual. A revoked_at column instead of a row deletion means history survives. And a scope field narrows a grant without inventing a role per course, which is where flat role systems start to fracture.
Where authorization decisions belong
In the domain layer, at the point of use, behind a small set of named checks. Not in route guards, not in the UI, and not in the query builder.
Route middleware is a useful place to establish context, such as which tenant is being addressed. It is a poor place to make the decision, because it cannot see the resource being touched, and because a background job or a webhook will not pass through it at all.
The check takes three things: the actor, carrying an identity and the tenant the request acts in; the capability being attempted; and the resource being touched.
- Platform operators are handled first and separately, because they act on tenants rather than inside them. Their access goes through their own scope check, and the branch is explicit so operator rights cannot quietly become tenant rights the first time somebody adds a role.
- If a resource was supplied, compare the resource's own tenant against the actor's tenant before anything else. A mismatch is refused, and refused before the capability lookup rather than after it.
- Only then look for an active, unrevoked grant matching this identity, this tenant and this capability. No grant means no action.
Two details carry most of the value. The tenant comes from the actor, taken from the authenticated session, rather than from a request parameter the caller controls. And the resource is passed in so its own tenant can be compared.
Isolation models and what each one buys
Shared schema with a tenant column
One schema, one table, a tenant_id on every tenant-scoped row. Cheapest to run and easiest to migrate, because a schema change applies everywhere at once. The weakness is that isolation is enforced by application code, so one forgotten predicate is a cross-tenant data leak. Appropriate when row-level security is available in the database and applied, shaky when it is not.
Schema per tenant
One schema per tenant in a shared database. Tenant-scoped queries need no predicate, which removes a whole error class. The costs are connection and memory overhead per schema, and migration complexity: a partially migrated fleet is worse than a uniformly older one.
Database per tenant
A separate database per tenant. The strongest application-level isolation, and the simplest story for per-tenant backup, restore and data deletion. Also the most expensive: pooling multiplies across databases, cross-tenant reporting needs a warehouse, and a migration is a fleet operation.
Write authorization code as if the database gave it no help. Then the isolation model becomes an operational choice you can revisit rather than a correctness dependency baked into every query. Decide the model early, and keep the decision reversible.
Platform operator versus tenant administrator
Different jobs, different places. A platform operator provisions and bills tenants and watches aggregate capacity. A tenant administrator manages one organisation's users, content and configuration. Conflating them is how an operator ends up with implicit read access to customer content, and how a tenant administrator ends up able to change platform configuration.
Keep operator capability in a separate table from tenant grants, so the two can be granted, reviewed and revoked independently, and an operator permission has no accidental path into the tenant grant table. Every operator action against tenant data should record the operator identity and the reason. And break-glass access should be a distinct, time-limited grant that expires on its own rather than a standing permission granted informally.
The LMS Platform concept Superwebtech has in development is a multi-tenant product in this problem domain: tenant-level users, instructors and learners have to be distinguishable from platform administration, and the product's design has to account for that separation. How it is isolated and authorized is an implementation decision that has not been fixed, so it is not described here as settled.
Background jobs, imports and webhooks are where tenant scope leaks
Every leak point worth worrying about is a code path that does not begin with an authenticated request. Those paths have no session to inherit a tenant from, so whatever supplies the tenant has to be made explicit.
A queued job is the clearest case. A request enqueues work carrying the tenant, the job runs minutes later with no user attached, and the handler reaches for a global scope because the tenant-scoped accessor needs one. Carry the tenant in the payload as a required field and have the handler build its own actor.
File imports have the same shape plus a harder question: which tenant does a row belong to? Resolving it from a user-supplied column is a security decision, and it deserves an explicit mapping step with a rejection path rather than a fallback to the current user's tenant.
Webhooks are the mirror image. A signature proves origin without proving which tenant the event concerns. If the provider can carry a tenant identifier, validate it against the signature. Otherwise resolve the tenant from the resource the event names and confirm it belongs to the tenant the integration is registered against.
- Enqueue the tenant identifier in the job payload and treat it as required, not optional.
- Resolve the owning tenant from the named resource, then check it against the integration registration.
- Give every background handler an actor built explicitly, so it cannot fall back to an ambient tenant.
- Add a unique constraint that includes the tenant on tables a job writes to, so a misrouted write fails instead of merging.
Testing cross-tenant isolation
Isolation is a claim about behaviour under adversarial input, so it needs adversarial tests rather than code review. Seed two tenants with distinguishable data, act as one of them, and assert that every path returns nothing belonging to the other.
- A read attempt: an administrator of tenant A requests a course owned by tenant B. Assert a refusal status, and assert the response body contains no part of B's course data, including the title.
- A write attempt: the same administrator sends an update for B's course. Assert a refusal status, then re-read the course and assert it is unchanged, because a refused write that still mutates is a real defect.
- A background attempt: enqueue an import job whose payload names tenant A but whose resource belongs to tenant B. Assert the target tenant's enrolment count is still zero, which forces the handler to combine the payload with the resource rather than trust either one.
What makes the suite useful is that it asserts on side effects as well as status codes, includes a deliberately malformed payload because the error path is where tenant scope is lost, and treats non-disclosure as part of the contract. Checking that the other tenant's data never appears in the response catches an endpoint that returns the right status with the wrong body.
Where the database supports row-level security, add a test that bypasses the application layer and queries through the privileged role. If that returns another tenant's rows, the application predicates are load-bearing.
Migrating from single-tenant to multi-tenant
Retrofitting tenancy across an existing dataset is one of the higher-risk refactors in web development, and the order below matters more than the individual steps.
- Add the tenant identifier to the schema first, defaulted to a single bootstrap tenant, and backfill existing rows to it. The application still behaves as single-tenant, so nothing changes for users.
- Make tenant scope required in the data access layer rather than optional. One code path that always reads the tenant is worth more than ten call sites that pass one in.
- Move roles out of the user table into tenant grants, backfilling each user's existing role as a grant in the bootstrap tenant.
- Separate operator capability from tenant grants, and audit which operator accounts currently hold standing access.
- Carry the tenant explicitly through jobs, imports and webhooks, then add the isolation suite and make it a gate.
- Only then introduce a second tenant. The first multi-tenant bug is usually found by the second real tenant rather than by a test, and the suite is what makes that finding cheap.
The order is chosen so each step is independently reversible. Steps one through three change the schema and the data access layer while the product still has one tenant, so they can ship during quiet periods. Steps four and five are where the audit work is.
If the LMS Platform or a similar multi-tenant system is on your roadmap, the tenancy and authorization decisions are worth settling during architecture rather than during the first migration. The stage-by-stage version of this argument, including where the review gates sit, is in from idea to production. The services page sets out what we cover, and contact is the place to start a conversation.


