Blog post

I Stopped Passing Tenant IDs Around My Application

Why tenant scope is an authority boundary, and how RequestContext makes multi-tenant trust explicit and enforceable.

I Stopped Passing Tenant IDs Around My Application

This code looks ordinary:

await updateRecord({
  tenantId: body.tenantId,
  userId: session.user.id,
  recordId: body.recordId,
  data: body.data,
});

The user is authenticated. The request contains a tenant ID. The repository can filter by that tenant. The happy path probably works.

That is precisely why this pattern bothered me only after I had built enough of a multi-tenant SaaS to see the problem.

Nothing in the function call tells me why body.tenantId should be trusted.

Perhaps the user selected that tenant in the UI. Perhaps the route contains /tenants/123. Perhaps a hidden field carried it through a form. All of those can be useful descriptions of what the client wants to do.

None of them establishes what the client is allowed to do.

The mistake is easy to make because a tenant ID has the shape of ordinary data. It is a string. It can be serialized, logged, stored, placed in a URL and passed to a function.

But its role in a multi-tenant operation is not ordinary.

The tenant does not merely describe which records the query should return. It defines the boundary inside which the actor may operate.

Once I framed it that way, passing tenant IDs around the application started to look wrong for the same reason that passing a client-supplied permission set would look wrong. The browser can make a claim. The server has to decide whether the claim has authority.

Where the trust decision belongs

A request can arrive with several pieces of useful information:

selected workspace
record ID
action
form data

The application still needs answers to different questions:

Who is the actor?
Which tenant may this actor operate in?
What is the actor allowed to do there?
How do I trace this operation?

I do not want every route, use case and repository to rediscover those answers independently.

That is how trust turns into convention.

One handler validates tenant membership. Another assumes middleware already did it. One repository requires a tenant filter. Another accepts the identifier directly because its caller “already knows” it is safe. The application works as long as every caller remembers the unwritten contract.

In a multi-tenant system, that is not a contract I want to rely on.

The model I moved toward is to establish authority once, at a trusted server boundary, and then carry the validated result through the operation.

Conceptually:

client request
    |
    v
authenticated identity
    |
    v
membership / active tenant resolution
    |
    v
authorization
    |
    v
RequestContext
    |
    v
application use case
    |
    v
persistence within tenant scope

A simplified type could look like this:

type RequestContext = {
  actorId: string;
  tenantScope: TenantScope;
  permissions: PermissionSet;
  requestId: string;
};

The exact fields are not the important part. The important part is who is allowed to create this object and from which evidence.

If lower-level code can construct an equivalent context from raw parameters, the type adds little. If a route can replace tenantScope with whatever arrived in the body, the boundary is cosmetic.

The context becomes useful when it represents a completed trust decision.

Then an application operation can receive something closer to:

await updateRecord({
  context,
  recordId,
  data,
});

The use case does not need to decide whether a tenant string supplied by the caller is authoritative. That question has already been resolved by the boundary responsible for identity and scope.

The browser can choose a workspace without granting one

This distinction matters in applications where a user belongs to more than one organization.

The UI may absolutely allow the user to select a workspace. That selection has to reach the server somehow.

But I treat it as a requested scope.

The server then checks whether the authenticated actor is a member of that tenant, whether the membership is valid, and whether the requested action is allowed. Only after those checks does the selected workspace become part of trusted context.

This sounds like a small semantic distinction. In code, it changes who owns the decision.

Without it:

client tenantId
    |
    v
query filter

With it:

client tenant selection
    |
    v
validate against authenticated identity
    |
    v
trusted tenant scope
    |
    v
query / mutation

The browser can ask for a context. It cannot mint one.

Propagation matters as much as creation

Establishing trusted tenant scope at the request boundary is only useful if the invariant survives the rest of the call path.

That means I want dangerous operations to make their authority requirements visible in their contracts.

A mutation that changes tenant-owned state should not quietly accept a raw tenant ID because it is convenient. A repository should not offer an easy unscoped method and rely on every caller to remember the filter. A helper deep in the application should not widen the scope or reconstruct it from request data.

The safe path needs to be the ordinary path.

This is also where types can help. A TenantScope that can only be constructed through approved server-side code communicates something different from tenantId: string. The type does not prove the authorization policy is correct, but it can make an entire class of casual mistakes harder to express.

The same is true of import boundaries and module ownership. If trusted context factories live beside ordinary application helpers and can be called from anywhere, the architecture still invites bypasses. The codebase should make it obvious where trust can be established and where it can only be consumed.

RLS is valuable, but it answers a different question

Database Row Level Security is a strong additional boundary in a multi-tenant SaaS.

I want it.

But I do not want application code to behave as if RLS removes the need for trusted application context.

The two layers protect different failure modes.

Application context answers questions such as:

  • which actor is performing this operation;
  • which tenant scope was established for that actor;
  • which capability is being exercised;
  • which business decision is being attempted.

RLS can ensure that a database session scoped to one tenant cannot casually read or write rows belonging to another.

That is an excellent backstop. It is not a substitute for knowing why the application believed the operation was allowed in the first place.

I prefer the two boundaries to reinforce each other:

trusted identity
      ↓
RequestContext / TenantScope
      ↓
application authorization
      ↓
tenant-bound database context
      ↓
RLS

If application code forgets a condition, the database still has a chance to fail closed. If database policy is too broad, the application boundary still expresses the intended authority. Neither layer should be an excuse to make the other vague.

The tests changed when the invariant changed

A happy-path test can prove that tenant A sees tenant A's records.

That is necessary evidence. It is weak evidence for isolation.

Once tenant scope is treated as a trust boundary, the more interesting tests are the ones that attempt to break the claim:

tenant A cannot read tenant B data
tenant A cannot mutate tenant B data
a caller cannot replace trusted tenant scope with request data
an unauthenticated request cannot construct application context
a user cannot request a tenant outside valid membership
a privileged/system path must be explicit rather than an accidental bypass

These tests are uncomfortable in a useful way. They exercise the system we are claiming cannot exist.

This is one of the broader lessons I wrote about in Working Is Not a Quality Metric: a successful flow tells me that expected behavior happened under expected conditions. It does not prove that the boundary survives hostile or simply incorrect conditions.

What I look for in review now

When I review a multi-tenant operation, I no longer start with the query.

I trace the source of authority.

Where was tenant scope first established?

Was it derived from authenticated identity and valid membership, or copied from caller-controlled data?

Can downstream code replace it?

Does authorization evaluate the action inside the established tenant scope?

Does persistence preserve that same scope?

Are privileged paths explicit?

Does the test suite attempt the wrong tenant rather than only demonstrating the right one?

These questions are more useful to me than checking whether every query happens to contain tenantId = ....

A filter can be correct while the authority behind the filter is wrong.

That distinction became important as the product moved beyond feature completeness and toward system guarantees, a transition I explored more broadly in When a SaaS Stops Being About Features.

The implementation detail I care about now is not that a tenant identifier travels successfully through the stack.

It is that, somewhere near the beginning of the operation, the application turns an untrusted request into a trusted scope — and that the rest of the system is designed so it does not have to guess again.

Continue exploring

Follow the same line of thought through themes, tags, or a broader local search across the archive.

Keep following the thread.