Skip to main content
Dejan Demonjić brand logoDejan Demonjić
Decision log
Work
About
Contact

Decision log / Decision record

The tenant comes from the token, not from the header

In a multi-tenant API the tenant a request acts in is decided from who is calling. A header may name one; the token says whether the caller may act there. The decision record: one middleware owns the header, the status code follows the caller, and authority that arrives through a replicated directory is trusted for one token lifetime.

Filed
2026-10-16
Topic
Multi-tenant platforms
Reading
9 min
Tags
#multi-tenancy#aspnetcore#authorization#security

Part 1 of 3 Multi-tenant platforms

  1. The tenant comes from the token, not from the header
  2. A schema per tenant: the database owns the name
  3. One repository, several solutions
Next A schema per tenant: the database owns the name

The claim

In a multi-tenant API, the tenant a request acts in is a decision about the caller, not about the request. A header may name a tenant. Whether the caller may act there is answered by the token, and by nothing the caller controls. One component makes that decision, owns the header the rest of the pipeline reads, and refuses with a status code chosen by who is asking. OWASP ASVS 5.0.0 states the requirement in one line: "multi-tenant applications use cross-tenant controls to ensure consumer operations will never affect tenants with which they do not have permissions to interact" (8.4.1, level 2). This note describes that control in Hexavera's cloud API, and why it looks the way it does.

Context

Hexavera's cloud serves people who belong to tenants arranged as a tree (a holding, its divisions, their plants) and service clients that run a customer site's runtime. Each tenant's rows live in a database schema of their own, which the next note in this series covers, so the tenant a request is bound to decides which schema its queries run in. There is no second line of defence below it.

The binding was first built as a chain of resolvers: the tenant header, then the token's claim, then a query value, first one found wins. Nothing compared the header with the token. A review on 2026-09-30 named the consequence in one sentence: a permission held in one tenant could be exercised in another. The resolvers also ran for callers with no token, and an unknown tenant name threw, so any route could be turned into a 500. The replacement was written down as a rule on 2026-10-01, before the code, and shipped the next day.

The rule

The tenant a request acts in is decided from who is calling:

Caller May act in When the request names anything else
Nobody signed in No tenant. Every route but the few anonymous ones answers 401 at authorization The header is dropped
A person The token's tenant, or a usable tenant below it; the token's tenant when none is named 404
A service client Its own tenant, and no other 403, whether that tenant exists or not; never 404
A hub connection The token's tenant The header is ignored
A health probe Not bound at all

"Usable" means active and granted this product. A query string chooses nothing.

The two status codes are deliberate, and RFC 9110 supports both. A 404 "indicates that the origin server did not find a current representation for the target resource or is not willing to disclose that one exists" (section 15.5.5). A person who names a tenant that does not exist, one that is switched off, one this product does not hold, or one outside their reach gets the same 404, so the answer tells them nothing about which tenants exist. A 403 "indicates that the server understood the request but refuses to fulfill it" and, with credentials present, that "the server considers them insufficient to grant access" (section 15.5.4). A service client always gets 403, for a reason that came from the client's own code: a site runtime reads a 404 to a delete as "already gone" and drops the change. A 404 that meant "not yours" would make it discard work it should have reported. The status follows who is calling, not what was asked.

One component owns the header

Everything downstream reads the acting tenant from the request header: the data layer picks the schema from it, the session reads it. That was true before the change and it would have been expensive to undo in the same step. So the middleware takes the caller's header off the request before anything else runs, and puts a header back only as the identifier of a tenant the caller was found able to act in. A request it does not bind carries no tenant header at all. The caller's value never reaches a line of code that trusts the header.

public async Task InvokeAsync(HttpContext context, ITenantReach reach, ITenantProvider tenants)
{
    var named = context.Request.Headers[TenantHeader].ToString();
    context.Request.Headers.Remove(TenantHeader);              // the caller's value is gone for good

    if (IsProbe(context.Request.Path) || context.User.Identity?.IsAuthenticated != true)
    {
        await next(context);                                   // no tenant; authorization answers 401
        return;
    }

    var caller = Caller.Of(context.User, named, context.Request.Path);
    var tenant = await reach.FindAsync(caller, context.RequestAborted);
    if (tenant is null)
    {
        Refuse(context, caller.IsServiceClient ? StatusCodes.Status403Forbidden : StatusCodes.Status404NotFound);
        return;
    }

    tenants.Bind(tenant);                                      // writes the header back, this time trusted
    try
    {
        await next(context);
    }
    finally
    {
        tenants.Clear();                                       // even when the request failed
    }
}

It sits after authentication, because the rule needs the caller, and before authorization and the first use of the tenant's schema. The refusal is thrown as the same problem-details shape every other 403 and 404 of the host uses, except on a hub route, where the shared handlers leave the request alone and the status is set directly; it is logged once with the caller's tenant and the name they sent, cut to 128 characters so a hostile header cannot fill the log. Two checks in it still read the path, the health probes and the hub routes, by a case-insensitive segment match; by the rule in the identity series they should be endpoint metadata as well, and the gap is noted here rather than passed over.

Reach: the tree, walked every time

A person may act in their token's tenant and in every usable tenant below it. "Below" is answered by climbing from the named tenant towards the root through active tenants until the walk meets the token's tenant or the chain ends. A tenant that is not usable cuts the tree beneath it: a plant under a switched-off division is out of reach of the division's parent. The tenant switcher in the user interface has always listed the tree that way, and a rule that let a caller act where the switcher would not take them would be two rules.

The walk has a depth bound of 32 levels, because nothing in the data stops a tenant from being made its own ancestor, and real trees are a few levels deep. It is not cached: a request in its own tenant costs the one read it already paid for, and a tenant below costs one read by primary key per level in between, plus one for the token's tenant. A cached tree would trade those reads for a window in which a reparented or deactivated subtree stays reachable, and that window was not acceptable at any price.

This is the interim rule, and it says so in the code. The target replaces "any tenant below" with "a tenant below in which the caller holds a grant", and adds an ancestor column so the answer is one read.

Authority that arrives by feed is trusted for one token lifetime

Which tenants are below, and whether they are active, comes from the API's own projection of the identity directory, kept current by reading a change feed. When an operator deactivates a tenant, the identity server revokes that tenant's own members at once, but a person of a tenant above it keeps refreshing their session and would keep acting in the deactivated tenant for as long as the feed lagged. A lag needs no outage; it can be induced as well as suffered.

So the binding fails closed on exactly that case. Once the projection has not been known to be current for longer than an access token lives (15 minutes, the same bound every other revocation in the platform keeps), a tenant below the token's is refused as out of reach until the feed has been read again. The same holds while the process has not yet read the feed to its end since it started, because a process started during a lag holds a projection of an age it cannot know. The token's own tenant is never refused this way: its revocations end its refreshes at the identity server, and that path has its own bound.

How it is tested

The middleware is tested as a unit against a fake reach and a fake feed state, with facts whose names are the rule: a user who names a tenant out of reach is answered 404 and goes no further; a service client that names another tenant is answered 403; a service client whose own tenant cannot be acted in is answered 403 and never 404; a hub request takes the token's tenant whatever it names; a tenant below the token's is refused once the feed has lagged past an access token's life; the tenant is forgotten once the request is over even when it failed. Above that, a booted host runs the same facts over real routes, and the live kit sends a plant's person against the division above, the sibling plant, the other division, the root, an unknown identifier and two header values, and asserts a write naming the division above leaves no row.

What it cost

  • A few reads by primary key for a request below the caller's own tenant, one per level in between and one for the token's tenant, accepted rather than cached.
  • A header the pipeline still reads, now owned by one component. It is a seam, and it is documented as one.
  • A support question the API refuses to answer: "does this tenant exist" cannot be told apart from "you may not act there" without the server log, by design.
  • During a feed outage, administrators of a parent tenant lose reach into their child tenants after fifteen minutes. That is the trade: a bounded loss of convenience against an unbounded window of stale authority.

What a team can take from this

Grep for the tenant header and for any query parameter that names a tenant, and count the places that read them. Then make one component decide, from the token, and let it own the header the others read. Choose the refusal's status code by who is calling and write the reason next to it; a client's behaviour is a fact your API has to live with. And when a piece of authority arrives through replication, bound how long you will trust it, and use the bound you already have.

DD

Dejan Demonjić

Principal software engineer, Belgrade

Designs and builds secure, multi-tenant .NET platforms. Questions about this entry, or a different view on it, are welcome by email.

  • Email
  • LinkedIn
  • GitHub
Previous Decide from the endpoint, not from the pathRate limits, security headers and access gates are per-endpoint decisions. ASP.NET Core accepts several spellings of one path, so a decision made by comparing the path is a decision made on incomplete information. Put it on the endpoint, and test that every endpoint is covered.Next Server-Sent Events in ASP.NET Core 10, and when to pick them over SignalRWhen a server talks and a client listens for the length of one request, Server-Sent Events is the right transport: plain HTTP, one direction, a text protocol a proxy carries and a browser reconnects by itself. .NET 10 made it a first-class result. The parts that bite are on the client: the protocol has rules, and some servers decide per response whether to answer with JSON or with a stream.

In this entry

  1. The claim
  2. Context
  3. The rule
  4. One component owns the header
  5. Reach: the tree, walked every time
  6. Authority that arrives by feed is trusted for one token lifetime
  7. How it is tested
  8. What it cost
  9. What a team can take from this
Dejan Demonjić

Designing and building secure, multi-tenant .NET platforms, and writing down the decisions behind them.

Read

  • Decision log
  • Work
  • RSS feed

Contact

  • dejan@demonjic.com
  • Write to me
  • About

Elsewhere

© Dejan Demonjić. Written in Belgrade.