fullstackhero/dotnet-starter-kit · error · UnauthorizedException
Tenant context is required.
Error message
Tenant context is required.
What it means
GetInvoicesQueryHandler lists invoices from the non-tenant-filtered BillingDbContext, so it must scope results itself: root may view across tenants (optionally narrowed by query.TenantId) while every other caller is forced to its own tenant id. If TenantInfo is null (no tenant context), it throws UnauthorizedException("Tenant context is required.") rather than returning an unscoped page.
Solutions
- Attach a tenant identifier to the request (X-Tenant-Id or __tenant__) so Finbuckle resolves TenantInfo.
- Check proxy/load-balancer config preserves the Host/X-Forwarded-Host header the tenant strategy matches on.
- For non-HTTP callers, wrap execution in a tenant scope or query via a root-authorized path.
- Confirm the tenant exists in the tenant store; unknown identifiers resolve to null TenantInfo.
Example fix
// before: header stripped by proxy
apiFetch('/invoices');
// after: set tenant explicitly
apiFetch('/invoices', { headers: { 'X-Tenant-Id': tenantId } }); Defensive patterns
Strategy: validation
Validate before calling
if (!tenantId) throw new Error('Tenant context required for invoice listing; set X-Tenant-Id or __tenant__');
// root callers may add ?tenantId=<target> to narrow the list Type guard
function hasTenant(t) { return typeof t === 'string' && t.length > 0; } Try / catch
try { return await apiFetch('/invoices?pageNumber=1&pageSize=20'); }
catch (e) { if (isUnauthorized(e)) { redirectToTenantSelection(); } throw e; } Prevention
- Always route invoice list calls through apiFetch so the tenant header is injected.
- For scheduled exports, run the job inside a tenant scope instead of calling the HTTP endpoint unscoped.
- Verify proxies don't strip Host/X-Forwarded-Host needed by host-based tenant resolution.
When it happens
Trigger: GET /invoices (v1 paginated list) without a resolvable tenant: no __tenant__ token/header, host not mapped to a tenant, request outside multitenant middleware, or invocation from a background worker without a tenant scope.
Common situations: Calling the list endpoint from a Hangfire job or scheduled report generator; Postman collection copied from a non-tenant admin route; tenant host strategy broken after a reverse-proxy rewrite strips the original host header.
Related errors
- Tenant context is required.
- Only the root operator may generate invoices across tenants.
- Tenant context is required.
- Tenant context is required.
- Tenant context is required.
AI-assisted analysis of fullstackhero/dotnet-starter-kit@3f2959e683 (2026-09-15).
Data as JSON: /api/errors/7a528f59ab10cc80.
Report an issue: GitHub.
Appendix: source
Thrown at src/Modules/Billing/Modules.Billing/Features/v1/Invoices/GetInvoices/GetInvoicesQueryHandler.cs:27
using Microsoft.EntityFrameworkCore;
namespace FSH.Modules.Billing.Features.v1.Invoices.GetInvoices;
public sealed class GetInvoicesQueryHandler(
BillingDbContext dbContext,
IMultiTenantContextAccessor<AppTenantInfo> tenantAccessor)
: IQueryHandler<GetInvoicesQuery, PagedResponse<InvoiceDto>>
{
public async ValueTask<PagedResponse<InvoiceDto>> Handle(GetInvoicesQuery query, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(query);
// BillingDbContext is not tenant-filtered: only root gets the cross-tenant view (optionally
// narrowed via query.TenantId); every other caller is forced to its own tenant.
var callerTenantId = tenantAccessor.MultiTenantContext?.TenantInfo?.Id
?? throw new UnauthorizedException("Tenant context is required.");
var isRoot = callerTenantId == MultitenancyConstants.Root.Id;
var tenantFilter = isRoot ? query.TenantId : callerTenantId;
var q = dbContext.Invoices.AsNoTracking().Include(i => i.LineItems).AsQueryable();
if (!string.IsNullOrWhiteSpace(tenantFilter))
{
q = q.Where(i => i.TenantId == tenantFilter);
}
if (query.Status is not null)
{
q = q.Where(i => i.Status == query.Status);
}
if (query.PeriodYear is not null)
{
q = q.Where(i => i.PeriodYear == query.PeriodYear);
}
if (query.PeriodMonth is not null)
{
q = q.Where(i => i.PeriodMonth == query.PeriodMonth);
}View on GitHub (pinned to 3f2959e683)