Tenant resolution in the middleware
The tenant must be known before anything else runs. Host, path or claim; pick one and make it boring.
The first multi-tenant web application I built resolved the tenant in an MVC action filter. That was 2011, ASP.NET MVC 3, and there was no pipeline to put it in anyway. The filter read the subdomain, looked it up, and stuffed a tenant object into HttpContext.Items. Every controller had it, every view had it, and for eleven months that was the whole story. Then someone added a WCF endpoint for a partner, which has no action filters, and a week later a nightly invoice run that has no request at all. Both needed the tenant. Both got a copy of the lookup code, with slightly different bugs.
The lesson was not that action filters are bad. It was that the tenant had been resolved in the wrong layer, and the cost of that showed up exactly where it always does: in the one path nobody drew. I moved the lookup into an HttpModule the following year, which was closer, and still tied the tenant to a request. Since OWIN, and properly since ASP.NET Core, the rule has been simple. The tenant is resolved once, early, in middleware, and stored in a scoped service. Everything downstream asks the service. Nothing downstream parses a host name.
Three places the tenant can hide
A request carries the tenant in one of three places, and the choice is mostly made for you by the product. The host is the classic one: acme.example.com or a custom domain like portal.acme.com. It is visible to the user, works before any cookie or token exists, and lets you pick a different identity authority per tenant. The cost is DNS and certificates, which is real work and the subject of sheet T-06.
The path is the second option: /t/acme/invoices. It is cheaper to operate, since one host and one certificate cover everyone, and it survives environments where you cannot hand out subdomains. It also means every link, every redirect and every API route carries a segment that must be validated on every hop, and I have seen a route template with an optional tenant segment resolve to the wrong tenant on a Friday afternoon.
The claim is the third: a tid or tenant_id claim in the token, populated by your identity provider. It is the most secure of the three, since the tenant is asserted by the authority rather than typed by the user, and for a pure API with machine-to-machine clients it is the right answer. It has one structural problem. You only get the claim after authentication, and authentication may itself depend on the tenant. I will come back to that.
My default for a product with a browser front end is host first, claim second: resolve by host, then verify after authentication that the token's tenant claim matches. A mismatch is a 403 and a log line I want to read.
Resolve once, then carry it in a scoped service
The resolver is a piece of middleware. It reads the host, asks the catalog, and either sets the tenant on a scoped holder or ends the request. In 2012 the services reached for HttpContext.Current themselves, and later, when that stopped being available, for an injected IHttpContextAccessor, which is the same mistake with better manners. Both worked until the first background job. The scoped service removes the dependency entirely: a controller, a hub, a hosted service and a test all get the tenant the same way.
public interface ITenantContext
{
TenantDescriptor Tenant { get; }
bool IsResolved { get; }
}
public sealed class TenantResolutionMiddleware(RequestDelegate next)
{
public async Task InvokeAsync(HttpContext http, ITenantCatalog catalog, TenantContextHolder holder)
{
var tenant = await catalog.FindByHostAsync(http.Request.Host.Host, http.RequestAborted);
if (tenant is null || tenant.State != TenantState.Online)
{
http.Response.StatusCode = StatusCodes.Status404NotFound;
return;
}
holder.Set(tenant);
await next(http);
}
}
TenantContextHolder is the scoped mutable implementation; ITenantContext is the read-only face that everything else sees. Registering the interface as a forward to the holder keeps the setter out of reach of code that has no business calling it. The catalog lookup itself is cached with a short expiry, because this runs on every request and the catalog is one database for the whole fleet.
builder.Services.AddScoped<TenantContextHolder>();
builder.Services.AddScoped<ITenantContext>(sp => sp.GetRequiredService<TenantContextHolder>());
var app = builder.Build();
app.UseMiddleware<TenantResolutionMiddleware>();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
Since .NET 6 the WebApplication host inserts routing at the start of the pipeline, so a resolver registered here already sees route values, which is what you want if you resolve by path segment instead of host.
Before authentication, not after
The ordering question is the one that trips up teams who start with the claim. If each tenant has its own OpenID Connect authority, or its own Entra tenant, or even just its own client id, the authentication handler needs to know the tenant to validate the token. That means the tenant must be resolved before UseAuthentication, which means it cannot come from the token. Host or path it is, and the claim becomes a check rather than a source.
If every tenant shares one authority and the token carries the tenant, the order can flip: authenticate, then resolve from the claim. That is a legitimate design for an API behind a single identity provider. It is just not one you can grow out of quietly, because the day a customer wants to bring their own directory, the resolver has to move in front of authentication and every place that assumed an authenticated user before the tenant was known has to be found.
Background jobs and everything without a request
A job has no host header and no route. What it has is a message, and the message carries the tenant id, always, as a first-class field rather than something buried in the payload. The job host then does what the middleware does: create a scope, set the holder, resolve the handler from that scope, run it. The handler cannot tell whether it was called from a controller or from a queue, which is the whole point.
public sealed class InvoiceRunJob(IServiceScopeFactory scopes, ITenantCatalog catalog)
{
public async Task RunAsync(Guid tenantId, CancellationToken ct)
{
var tenant = await catalog.GetAsync(tenantId, ct)
?? throw new TenantNotFoundException(tenantId);
await using var scope = scopes.CreateAsyncScope();
scope.ServiceProvider.GetRequiredService<TenantContextHolder>().Set(tenant);
var handler = scope.ServiceProvider.GetRequiredService<InvoiceRunHandler>();
await handler.ExecuteAsync(ct);
}
}
Two rules follow from this. A job that fans out over all tenants creates one scope per tenant, never one scope for the loop; a scoped DbContext with a tenant filter baked in at construction will otherwise happily run tenant B's invoices against tenant A's filter. And a job that arrives without a tenant id is rejected, not defaulted. There is no default tenant. The one time we had one, for "system" jobs, it ended up owning 14,000 rows that belonged to a real customer.
The boring version
Resolve by host in middleware before authentication, store the result in a scoped ITenantContext, verify the token's tenant claim against it afterwards, and make every message carry the tenant id so jobs can build the same scope. None of this is clever. The 2012 version was cleverer, with its action filter and its HttpContext.Items, and it broke the first time the product grew a second entry point. The boring version has survived two framework rewrites and four entry points since, and the code that reads the tenant has not changed in either of them.
Drawn from
- ASP.NET Core middlewarelearn.microsoft.com
- Dependency injection in ASP.NET Corelearn.microsoft.com
- Background tasks with hosted serviceslearn.microsoft.com