Resolving the Tenant on Every Request
Header, route, subdomain or query string: how the platform decides who is asking before it touches any data, how tenant identity becomes a connection string and a schema, and where the caching knives are hidden.
In a single-tenant app, a broken auth check leaks a page. In a multi-tenant app, a broken tenant check serves customer A's data to customer B — and no stack trace tells you it happened. So before any handler in the platform touches data, one question gets answered: who is asking? It has to be answered on every request, it has to be cheap, and it has to be right.
Part 1 covered how far apart tenants live. This part covers how a request finds its way to the right one.
Four places a tenant can hide
Clients are not uniform. A SPA sends headers, a webhook has a route parameter, a white-labelled product arrives on a subdomain, and someone will always paste a query string into Slack. The resolver tries them in a fixed priority order and stops at the first hit:
private async Task<TenantIdentifier?> ResolveTenantFromRequestAsync(HttpContext httpContext)
{
var tenantFromHeader = ResolveFromHeader(httpContext);
if (tenantFromHeader != null) return tenantFromHeader;
var tenantFromRoute = ResolveFromRoute(httpContext);
if (tenantFromRoute != null) return tenantFromRoute;
var tenantFromSubdomain = await ResolveFromSubdomainAsync(httpContext);
if (tenantFromSubdomain != null) return tenantFromSubdomain;
var tenantFromQuery = ResolveFromQuery(httpContext);
if (tenantFromQuery != null) return tenantFromQuery;
logger.LogWarning("Could not resolve tenant from HTTP context");
return null;
}
The ordering is a policy decision disguised as an if chain. Headers win because they are what my first-party clients send deliberately; query strings lose because they are the easiest thing for a user to tamper with or accidentally share. If two mechanisms disagree, the more trusted one silently wins — which is exactly what I want, and worth writing down, because six months later it reads like an accident.
Header resolution accepts two spellings — a GUID id and a human-readable slug:
if (httpContext.Request.Headers.TryGetValue("X-Tenant-Id", out var tenantIdHeader))
{
var tenantId = tenantIdHeader.FirstOrDefault();
if (!string.IsNullOrEmpty(tenantId) && Guid.TryParse(tenantId, out var guid))
return new TenantIdentifier(guid.ToString());
}
if (httpContext.Request.Headers.TryGetValue("X-Tenant-Identifier", out var tenantIdentifierHeader))
{
var tenantIdentifier = tenantIdentifierHeader.FirstOrDefault();
if (!string.IsNullOrEmpty(tenantIdentifier)) return new TenantIdentifier(tenantIdentifier);
}
Note the asymmetry: X-Tenant-Id must parse as a GUID or it is ignored, while the slug variant is taken at face value and validated later. Rejecting malformed input at the door keeps garbage out of the cache key space.
Subdomains, the production-shaped option
Subdomain resolution is the one that looks like a real product: acme.myplatform.example should mean tenant acme without the client sending anything. The base domain comes from configuration, and whatever precedes it must survive validation:
var baseDomain = configuration["MultiTenancy:BaseDomain"];
if (!host.EndsWith(baseDomain, StringComparison.OrdinalIgnoreCase)) return null;
var subdomain = host.Substring(0, host.Length - baseDomain.Length - 1);
if (Guid.TryParse(subdomain, out var guid)) return new TenantIdentifier(guid.ToString());
if (IsValidTenantIdentifier(subdomain)) return new TenantIdentifier(subdomain);
IsValidTenantIdentifier is deliberately boring — alphanumerics, hyphens, underscores, 3 to 50 characters. That boringness matters more than it looks: this string eventually participates in deriving a PostgreSQL schema name, so the whitelist here is the first fence on a path that ends in DDL.
I will admit to one wart in the route-based resolver: it digs tenantId out of RouteValues via reflection, to keep the shared library free of a framework dependency it only needs for one property. It works; it is also the kind of cleverness that breaks silently on a framework upgrade. The retrospective at the end of this series has a better answer.
Middleware that fails open — on purpose
Resolution runs once per request in middleware, and the result is stashed in HttpContext.Items where everything downstream can reach it:
public async Task InvokeAsync(HttpContext context, ITenantResolver tenantResolver)
{
var tenant = await tenantResolver.ResolveTenantAsync(context);
if (tenant != null)
context.Items["TenantIdentifier"] = tenant;
else
logger.LogWarning("Could not resolve tenant for request {RequestPath}", context.Request.Path);
await next(context); // continue even without a tenant
}
The middleware fails open: no tenant, no 403, the pipeline continues. That sounds negligent until you list what legitimately has no tenant — /health, Swagger, the migration endpoints from later in this series. The contract is that data endpoints must demand a tenant themselves, and the TenantContext facade gives them a one-liner for it:
public void RequireActiveTenant()
{
var tenant = GetCurrentTenant();
if (tenant == null) throw new InvalidOperationException("No tenant context available");
if (!IsCurrentTenantActive()) throw new InvalidOperationException($"Tenant {tenant.Value} is not active");
}
Fail-open middleware plus fail-closed endpoints is a fine design if the second half is enforced. It is also a standing invitation for a new endpoint to forget. If I could make one thing a compile error, it would be this.
From identity to connection
Knowing the tenant is half the trip; the request still needs a connection string and a schema. TenantConnectionProvider owns both, with a memory cache in front — connection strings for ten minutes, active-status checks for five. The schema name is pure convention:
// For GUID-based tenants, use the first 8 characters
if (Guid.TryParse(tenantId, out var guid)) return $"tenant_{guid.ToString("N")[..8]}";
// For string-based tenants, sanitize and lower-case
var sanitizedId = SanitizeForSchemaName(tenantId);
return $"tenant_{sanitizedId}";
SanitizeForSchemaName strips anything that is not a letter, digit or underscore, prefixes an underscore if the result starts with a digit, and truncates to 63 characters — PostgreSQL's identifier limit. Every one of those rules exists because PostgreSQL will enforce it anyway; doing it here just moves the failure somewhere debuggable.
On the data-plane API there is a second, blunter mechanism: a tenant-schema header read by SchemaResolutionService, which feeds the DI registration that builds the request's DbContext:
services.AddScoped<SchemaAwareDbContext>(provider =>
{
var factory = provider.GetRequiredService<SchemaAwareDbContextFactory>();
var schemaService = provider.GetRequiredService<SchemaResolutionService>();
var schema = schemaService.GetSchemaFromHeader();
return factory.CreateForSchema(schema);
});
One scoped registration, and every repository in the request transparently operates on the caller's schema. It is the payoff of the whole resolution pipeline: by the time application code runs, “which tenant?” has already been answered and baked into the context.
The honest trade-offs
Caching cuts both ways. A five-minute TTL on active-status means a tenant I suspend keeps working for up to five minutes. For this platform that is acceptable; for a “cut off access now” compliance requirement it is not, and the fix — pub/sub invalidation through the Redis instance already in the stack — is real work I have not done yet.
The tenant-schema header is a trust decision. A raw schema name in a header is fine when the header is set by an internal gateway that already authenticated the caller; it is a self-service tenant-hopping API if the header reaches you from the public internet. This platform assumes the former. Say the assumption out loud in your deployment docs, because the code cannot enforce it.
Resolution caches by URL, not by tenant. The cache key includes host, path and query — so identical requests skip re-resolution, but the same tenant arriving via different URLs occupies multiple cache slots. Cheap, effective, slightly wasteful. I can live with it.
Everything so far assumes the tenant's schema exists and has the right tables in it. Making that true — with one EF Core migration set serving every schema — is where the real fight starts, and that is next.