DEV Community

Jamie Hurley
Jamie Hurley

Posted on • Originally published at blazorblueprint.com

We Built Multi-Tenancy Into a Blazor App. Here's Every Layer Preventing Data Leaks.

Last year our team shipped a Blazor app to a client running three tenants. Within a week, a support ticket came in: "I can see another company's data." Turned out we'd forgotten a query filter on one entity. One table.

We rebuilt the entire multi-tenancy layer after that. Not just the query filters. Everything. Four separate layers of isolation so that if any single one has a bug, the others still block the leak.

For that reason, I built BlazorBluePrint, a production-ready Blazor WebAssembly template, and multi-tenancy was the hardest part to get right. This is exactly how it works, with real code from the project.

Four Layers, One Goal

Here's the stack:

  1. Middleware figures out which tenant owns this request
  2. EF Core query filters scope every database read to that tenant
  3. SaveChanges override stamps every new record with the tenant ID
  4. JWT claims carry the tenant ID in a signed token

If the query filter has a bug, the middleware already blocked unauthorized access. If the middleware has a bug, the JWT claims still carry the correct tenant. If someone bypasses auth entirely, SaveChanges still won't let them write to the wrong tenant.

No single point of failure.

Layer 1: Every Entity Knows Its Tenant

Every tenant-scoped entity inherits from a BaseEntity that includes a TenantId:

public abstract class BaseEntity
{
    public required Guid Id { get; set; }
    public required Guid TenantId { get; set; }
    public DateTime CreatedAt { get; set; }
    public required string CreatedBy { get; set; }
    public bool IsDeleted { get; set; }

    [Timestamp]
    public byte[] RowVersion { get; set; } = [];
}
Enter fullscreen mode Exit fullscreen mode

This is the foundation. If an entity extends BaseEntity, it's tenant-scoped. Period. There's no way to create a record without a tenant ID because the required keyword enforces that at compile time.

Some entities like Tenant itself and AppUser (ASP.NET Identity) are intentionally not tenant-scoped. They exist at the platform level.

Layer 2: Tenant Resolution - Who's Asking?

When a request comes in, we need to figure out which tenant it belongs to. Our TenantResolver uses a priority chain:

public class TenantResolver : ITenantResolver
{
    public Guid? GetTenantIdFromRequest()
    {
        // Priority 1: JWT claims (authenticated users)
        var tenantId = GetTenantIdFromClaims();
        if (tenantId.HasValue) return tenantId;

        // Priority 2: X-TenantId header (API clients)
        tenantId = GetTenantIdFromHeader();
        if (tenantId.HasValue) return tenantId;

        // Priority 3: Domain name (vanity URLs)
        return GetTenantIdFromDomain();
    }
}
Enter fullscreen mode Exit fullscreen mode

Why this order? JWT claims are the most trustworthy since they're signed and verified. The header is useful for API integrations. Domain-based resolution enables vanity URLs (acme.yourapp.com).

The resolved tenant is stored in a TenantContext using AsyncLocal<T>:

public class TenantContext : ITenantContext
{
    private readonly AsyncLocal<Tenant?> _currentTenant = new();

    public Tenant? CurrentTenant => _currentTenant.Value;
    public void SetCurrentTenant(Tenant tenant)
        => _currentTenant.Value = tenant;
}
Enter fullscreen mode Exit fullscreen mode

Our team uses AsyncLocal<T> instead of ThreadLocal<T> because async methods can resume on different threads. ThreadLocal would lose the tenant context after an await. That's a subtle but catastrophic bug in a multi-tenant system.

Layer 3: The Middleware - Security Boundary

The TenantMiddleware runs early in the pipeline and acts as a security gate:

public class TenantMiddleware
{
    public async Task InvokeAsync(HttpContext context)
    {
        try
        {
            // Single-tenant mode: always use root tenant
            if (_licenseConfig.IsSingleTenant)
            {
                var rootTenant = await LoadTenant(Tenant.RootTenantId);
                _tenantContext.SetCurrentTenant(rootTenant);
                await _next(context);
                return;
            }

            // Multi-tenant: resolve from request
            var tenantId = _tenantResolver.GetTenantIdFromRequest();

            if (tenantId == null)
            {
                context.Response.StatusCode = 400;
                return;
            }

            var tenant = await LoadTenant(tenantId.Value);

            if (tenant == null || !tenant.IsActive)
            {
                context.Response.StatusCode = 403;
                return;
            }

            // Validate membership for header-based access
            if (IsHeaderBasedResolution(context))
            {
                var userId = GetUserId(context);
                if (!await UserBelongsToTenant(userId, tenantId.Value))
                {
                    context.Response.StatusCode = 403;
                    return;
                }
            }

            _tenantContext.SetCurrentTenant(tenant);
            await _next(context);
        }
        finally
        {
            // Always clear to prevent tenant leakage between requests
            _tenantContext.ClearCurrentTenant();
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Three critical details here:

  1. Membership validation. If a user sends a X-TenantId header, we verify they actually belong to that tenant. You can't just guess a tenant ID and access their data.
  2. Inactive tenant blocking. Deactivated tenants get a 403 immediately.
  3. finally block cleanup. The tenant context is always cleared, even if the request throws. Without this, a tenant context could leak to the next request on the same scope.

Layer 4: EF Core Query Filters - Automatic Isolation

This is where the magic happens. EF Core's global query filters ensure that every query is automatically scoped to the current tenant:

protected override void OnModelCreating(ModelBuilder builder)
{
    // Every tenant-scoped entity gets a filter
    builder.Entity<Permission>()
        .HasQueryFilter(e =>
            IsDedicatedTenantDatabase ||
            e.TenantId == CurrentTenantId);

    builder.Entity<SecuritySettings>()
        .HasQueryFilter(e =>
            IsDedicatedTenantDatabase ||
            e.TenantId == CurrentTenantId);

    builder.Entity<Notification>()
        .HasQueryFilter(e =>
            IsDedicatedTenantDatabase ||
            e.TenantId == CurrentTenantId);

    // ... same pattern for all tenant-scoped entities
}
Enter fullscreen mode Exit fullscreen mode

Notice the IsDedicatedTenantDatabase flag. Our team supports two isolation models:

  • Shared database (default): All tenants in one database, isolated by TenantId column
  • Dedicated database: Premium tenants get their own database, so filters are bypassed since all data already belongs to one tenant

This means a developer writing a feature never thinks about tenancy. They write _context.Notifications.ToListAsync() and EF Core handles the rest. The filter is invisible and inescapable.

But What About Writes?

Query filters handle reads. For writes, we intercept SaveChangesAsync:

public override async Task<int> SaveChangesAsync(
    CancellationToken cancellationToken = default)
{
    foreach (var entry in ChangeTracker.Entries<BaseEntity>())
    {
        if (entry.State == EntityState.Added)
        {
            // Auto-stamp TenantId on new entities
            if (entry.Entity.TenantId == Guid.Empty)
            {
                entry.Entity.TenantId = CurrentTenantId;
            }
        }
    }

    return await base.SaveChangesAsync(cancellationToken);
}
Enter fullscreen mode Exit fullscreen mode

If a developer forgets to set TenantId, the system fills it in. If CurrentTenantId is empty (shouldn't happen, but defense in depth), it throws an InvalidOperationException rather than saving a record with no tenant.

Layer 5: JWT Claims: Tenant in the Token

When a user logs in, our system embeds their tenant directly in the JWT:

private async Task<string> GenerateJwtToken(AppUser user, Guid tenantId)
{
    var claims = new List<Claim>
    {
        new(ClaimTypes.NameIdentifier, user.Id),
        new(ClaimTypes.Name, user.UserName!),
        new(ClaimTypes.Email, user.Email!),
        new("TenantId", tenantId.ToString()),
        new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
    };

    // Add role and permission claims
    foreach (var role in await _userManager.GetRolesAsync(user))
    {
        claims.Add(new Claim(ClaimTypes.Role, role));
    }

    var key = new SymmetricSecurityKey(
        Encoding.UTF8.GetBytes(_jwtSettings.Key));
    var token = new JwtSecurityToken(
        issuer: _jwtSettings.Issuer,
        audience: _jwtSettings.Audience,
        claims: claims,
        expires: DateTime.UtcNow.AddHours(3),
        signingCredentials: new SigningCredentials(
            key, SecurityAlgorithms.HmacSha256));

    return new JwtSecurityTokenHandler().WriteToken(token);
}
Enter fullscreen mode Exit fullscreen mode

This means the tenant context travels with every authenticated request, so no database lookup is needed on subsequent calls. The TenantResolver reads it directly from the signed JWT claims.

Users Can Belong to Multiple Tenants

A single user account can access multiple tenants via the TenantUser join table:

public class TenantUser
{
    public Guid Id { get; set; }
    public string UserId { get; set; }        // FK → AspNetUsers
    public Guid TenantId { get; set; }         // FK → Tenants
    public bool IsActive { get; set; }
    public virtual Tenant Tenant { get; set; }
}
Enter fullscreen mode Exit fullscreen mode

During login, the system queries which tenants the user belongs to and returns them all:

var userTenants = await _unitOfWork.TenantUsers.Query()
    .Where(tu => tu.UserId == user.Id
              && tu.IsActive
              && tu.Tenant.IsActive)
    .Select(tu => new { tu.TenantId, tu.Tenant.Name })
    .ToListAsync();
Enter fullscreen mode Exit fullscreen mode

The frontend can then offer a tenant picker. Switching tenants issues a new JWT with a different TenantId claim. No page reload needed.

Single-Tenant Mode: Same Code, Zero Overhead

Here's a design decision our team is proud of: the entire multi-tenancy system can be toggled off with a license key.

In single-tenant mode:

  • The middleware always uses the root tenant (00000000-0000-0000-0000-000000000001)
  • Query filters still apply (pointing to the root tenant ID)
  • The tenant management UI is hidden
  • New users are auto-assigned to the root tenant

Zero code branches. The architecture is identical. It just always resolves to the same tenant. This means single-tenant customers get the same battle-tested data isolation code, and upgrading to multi-tenant is a license change, not a migration.

Things That Bit Us

DbContext pooling breaks everything. EF Core's AddDbContextPool reuses contexts across requests. Our DbContext needs ITenantContext injected per-request to know which tenant to filter. Pooled contexts would serve stale tenant data. Our team uses AddDbContext instead. Small perf cost, huge correctness win.

Seeding data is tricky. There's no HttpContext or JWT during startup. Our seeder has to explicitly set TenantId on every entity and use IgnoreQueryFilters() to check for existing data across tenants.

Admin dashboards need explicit opt-out. Cross-tenant queries like "show all subscriptions" require you to call IgnoreQueryFilters():

var allSubscriptions = await _context.Tenants
    .IgnoreQueryFilters()
    .Where(t => t.SubscriptionStatus == SubscriptionStatus.Active)
    .ToListAsync();
Enter fullscreen mode Exit fullscreen mode

This is intentional friction. You have to say "I know I'm crossing tenant boundaries" in your code. If you forget a filter, data stays isolated. If you need to cross, you opt in explicitly.

Audit logs skip the filter on purpose. An admin investigating a security incident needs the full picture, not a tenant-filtered slice. Our team handles access control at the service layer instead.

What This Gets You

A developer building a new feature never thinks about tenancy. They extend BaseEntity, write normal queries, and the system handles isolation across reads, writes, and auth. Four layers, working independently, each one catching what the others might miss.

Our team learned the hard way that multi-tenancy is easy to get 90% right and catastrophic to get wrong. That missing query filter on one table? It took five minutes to fix and a week of trust to rebuild.

Build it once. Build it right. Or just use a template that already did.

BlazorBluePrint is the Blazor WASM template I wish I had before that support ticket. Multi-tenancy, auth, permissions, Stripe billing, 15+ languages, and 50+ features. Try the live demo or check out blazorblueprint.com.

Top comments (0)