Skip to content

Tenant Resolvers — Resolution Pipeline

The tenant resolution pipeline executes on every HTTP request. Resolvers run in Order (ascending) — the first non-null result wins. The resolved tenant is stored in an AsyncLocal context accessible via ICurrentTenant.

flowchart LR
    R[Request] --> D{Domain?}
    D -->|subdomain| T[TenantInfo]
    D -->|no match| H{Header?}
    H -->|X-Tenant-Id| T
    H -->|missing| J{JWT claim?}
    J -->|tenant_id| T
    J -->|missing| Q{QueryString?}
    Q -->|__tenant| T
    Q -->|missing| N[No tenant]
    T --> M[Middleware sets AsyncLocal]
    M --> A[Request continues]
ResolverOrderSourceEnabled byUse case
DomainTenantResolver50Host header → identifier → ITenantReaderDomainTemplate setSaaS with branded URLs
HeaderTenantResolver100X-Tenant-Id HTTP headerAlwaysService-to-service, BFF
JwtClaimTenantResolver200tenant_id JWT claimAlwaysEnd-user via Keycloak/OIDC
QueryStringTenantResolver300?__tenant={guid}QueryStringParamName setDev/debug

Extracts the tenant identifier from the request Host header using a configurable template. The {0} placeholder matches the tenant’s Identifier field (slug), which is resolved via ITenantReader.FindByIdentifierAsync().

{
"MultiTenancy": {
"DomainTemplate": "{0}.monsaas.com"
}
}

Examples:

Request HostTemplateExtracted identifier
acme.monsaas.com{0}.monsaas.comacme
my-tenant.app.local{0}.app.localmy-tenant
tenant1.sub.example.com{0}.sub.example.comtenant1
monsaas.com{0}.monsaas.comNo match (null)

The resolver returns null when:

  • DomainTemplate is not configured (disabled)
  • The host doesn’t match the template pattern
  • No tenant with the extracted identifier exists
  • The matched tenant is inactive (Activated = false)

Reads the tenant GUID from the X-Tenant-Id HTTP header (configurable via TenantIdHeaderName). Always active.

Terminal window
curl -H "X-Tenant-Id: 3fa85f64-5694-4b5a-b7d9-c4f11f0b7f5e" \
http://localhost:5000/api/v1/products

Reads the tenant GUID from the tenant_id JWT claim (configurable via TenantIdClaimType). Requires UseAuthentication() before UseGranitMultiTenancy().

Reads the tenant GUID from a query parameter. Intended for development and debugging — lowest priority (order 300).

GET /api/v1/products?__tenant=3fa85f64-5694-4b5a-b7d9-c4f11f0b7f5e

Disabled by default (QueryStringParamName = null). Enable in appsettings.Development.json:

{
"MultiTenancy": {
"QueryStringParamName": "__tenant"
}
}

Implement ITenantResolver for additional resolution strategies:

public sealed class CookieTenantResolver(
IOptions<MultiTenancyOptions> options) : ITenantResolver
{
public int Order => 150; // Between header and JWT
public Task<TenantInfo?> ResolveAsync(
HttpContext context, CancellationToken cancellationToken = default)
{
if (!context.Request.Cookies.TryGetValue("tenant_id", out string? value))
return Task.FromResult<TenantInfo?>(null);
if (!Guid.TryParse(value, out Guid tenantId))
return Task.FromResult<TenantInfo?>(null);
return Task.FromResult<TenantInfo?>(new TenantInfo(tenantId));
}
}

Register in DI — the pipeline auto-discovers all ITenantResolver implementations:

services.AddScoped<ITenantResolver, CookieTenantResolver>();

By default, the middleware verifies that the resolved tenant ID exists in the tenant store (ITenantReader.ExistsAsync). This prevents phantom tenants — arbitrary GUIDs from spoofed headers creating orphaned data.

{
"MultiTenancy": {
"ValidateTenantExistence": true
}
}

Disable only in test environments where no tenant store is available.

The X-Tenant-Id header is accepted by default without cross-validation (Unrestricted). This is safe when the header is set by a trusted BFF or reverse proxy. For direct API exposure, enable CrossValidate:

{
"MultiTenancy": {
"HeaderTrustMode": "CrossValidate"
}
}
ModeBehaviorUse case
UnrestrictedHeader accepted as-isBehind BFF/reverse proxy
CrossValidateHeader must match JWT claim (403 on mismatch)Direct API exposure
PropertyDefaultDescription
IsEnabledtrueEnable/disable tenant resolution
TenantIdClaimType"tenant_id"JWT claim name for tenant ID
TenantIdHeaderName"X-Tenant-Id"HTTP header name for tenant ID
HeaderTrustMode"Unrestricted"Unrestricted or CrossValidate
ValidateTenantExistencetrueVerify resolved tenant exists in tenant store
DomainTemplatenullDomain template with {0} placeholder (e.g. "{0}.monsaas.com")
QueryStringParamNamenullQuery string param name (null = disabled, set "__tenant" in dev)

The middleware emits OpenTelemetry counters under the Granit.MultiTenancy meter:

MetricTagsDescription
granit.multi_tenancy.resolution.succeededtenant_id, resolver_typeSuccessful resolutions
granit.multi_tenancy.resolution.failedtenant_idNo resolver matched or cross-validation rejected
granit.multi_tenancy.context.switchedtenant_idExplicit ICurrentTenant.Change() calls

The resolver_type tag identifies which resolver matched (DomainTenantResolver, HeaderTenantResolver, etc.).