Troubleshooting
Common symptoms and what causes them. Most isolation surprises come down to one of: no tenant in scope,
the wrong TKey, middleware ordering, or the interceptor not attached.
Startup fails with "no tenant resolvers were registered" / "no tenant store was registered"
AddTenantry validates configuration at startup. You called AddTenantry but forgot a resolver or a
store.
- Add at least one resolver:
tenant.ResolveFromHeader(...),ResolveFromClaim(...), etc., orUseResolver(...). - Add exactly one store:
tenant.UseInMemoryStore(...)ortenant.UseStore<T>().
(AddTenantryCore does not perform this validation — see Non-HTTP hosts.)
Queries return no rows for a valid tenant
The query filter is fail-closed: when CurrentTenantId is the default value it matches nothing. Check,
in order:
- Is a tenant actually in scope? Inject
ITenantContext<TKey>and confirmHasTenantis true at the point of the query. On the web,UseTenantry()must have run and resolved a tenant. In a worker, you must be inside a scope fromITenantScopeFactory(orBeginScope). - Did the request resolve a tenant? A missing/blank header (or other source) means no tenant. If
the endpoint should require one, add
.RequireTenant()so you get a clear400instead of silent empties. - Is the entity actually tenant-scoped? It must implement
ITenantScoped<TKey>with the sameTKeyyou registered. AGuidregistration plus anITenantScoped<string>entity never lines up. - Is the tenant id the default value? Avoid
Guid.Empty/0as a real tenant id; the fail-closed guard treats the default as "no tenant".
Queries return all tenants' rows
- You probably called
IgnoreQueryFilters()somewhere (directly, or via a shared queryable helper). - The entity does not implement
ITenantScoped<TKey>, so it is treated as global. If it should be isolated, implement the interface (or derive fromTenantScoped<TKey>). ApplyTenantFilterswas not called. With Option A you must call it inOnModelCreating; withMultiTenantDbContext<TKey>you must callbase.OnModelCreating(modelBuilder).
TenantId is not being stamped on insert
- The interceptor is not attached: ensure
.AddTenantInterceptors(sp)is in theAddDbContextcallback for that context, and thatAddEfCoreIsolation()was called in the registration lambda. - No tenant is in scope at
SaveChanges: the interceptor logs a warning and stamps nothing. Save inside a tenant scope.
TenantIsolationViolationException on save
This is the system working: a Modified/Deleted entity (or, with DetectSpoofedWrites, an Added
one with an explicit wrong id) belongs to a different tenant than the current scope. The exception's
OffendingTenantId and ExpectedTenantId tell you which.
- You loaded an entity in one tenant's scope and modified it in another's. Do tenant work inside the owning tenant's scope.
- You set
TenantIdmanually to another tenant. Don't set it — let the interceptor stamp it. - A legitimate cross-tenant admin operation: use a fresh
DbContextinside the correct tenant's scope per tenant, orIgnoreQueryFilters()for reads.SaveChangesstill validates every write against the current scope, butIgnoreQueryFilters()combined withExecuteUpdate/ExecuteDeleteaffects every tenant, so treat it as privileged.
Filter uses a stale tenant / leaks across requests
Almost always a DbContext wiring mistake. The filter must read the tenant from the DbContext,
not a captured service:
- Implement
ITenantAwareDbContext<TKey>and exposeCurrentTenantId => _tenantContext.CurrentTenantId, thenApplyTenantFilters<TKey, TContext>(this). Passingthisis what lets EF Core re-evaluate the tenant per query. See EF Core integration. - Inject
ITenantContext<TKey>(read-only) into the context, notITenantScope<TKey>.
Claim-based resolution or validation never matches
UseTenantry()runs beforeUseAuthentication(), soHttpContext.Useris empty when the resolver/validator runs. MoveUseTenantry()afterUseAuthentication().- The claim type does not match (
ResolveFromClaim("tenant_id")vs. the actual claim name). - The claim value does not parse to your
TKey(e.g. a non-GUID string for aGuidkey).
Route-value resolution returns null
Routing must run before the middleware so the route value exists. With WebApplication this is
automatic; in a custom pipeline, ensure UseRouting() precedes UseTenantry().
Subdomain resolution returns null on localhost
ResolveFromSubdomain requires at least three dot-separated host segments, so localhost and
acme.localhost resolve to null. Use header resolution for local development.
Background/queued work loses the tenant
The AsyncLocal tenant flows down into awaited work but does not survive past the scope's disposal.
Capture the tenant id when enqueuing and run the deferred work with
ITenantScopeFactory.RunInScopeAsync(id, …). See Non-HTTP hosts.
The tenant is missing after a helper opened a scope
A scope opened inside an async method is not active for that method's caller: an async method's
changes to an AsyncLocal are undone when it returns. Open the scope in the method that does the work
(await using var scope = scopes.CreateScope(tenant);), or pass the work in with
ITenantScopeFactory.RunInScopeAsync.
AOT/trim warnings from the EF Core integration
Expected. ApplyTenantFilters and MultiTenantDbContext<TKey> are annotated [RequiresDynamicCode]
and [RequiresUnreferencedCode] because query filters build expression trees. EF Core is not
AOT-compatible; do not publish an EF-Core-backed app with Native AOT. See
AOT & trimming.