Table of Contents
TL;DR
In multi-tenant SaaS APIs, handling authentication correctly is non-negotiable. While ASP.NET Core offers filters and authorization filters, I consistently reach for custom middleware. Here’s why it wins for me in real-world SaaS workloads.
Why Authorization Filters Fall Short in SaaS
Tight coupling to MVC pipeline: Authorization filters only work within the MVC/Web API controller lifecycle. Your gRPC endpoints, health checks, and SignalR hubs? You’re out of luck. In SaaS platforms where you often mix multiple communication patterns, this limitation becomes painful fast.
Late execution in request pipeline: Filters run after routing has occurred. You’ve already burned CPU cycles determining which controller and action to hit, even for requests that should be rejected immediately. When you’re processing thousands of requests per second, this matters.
Complex tenant-based logic: Applying different authorization rules per tenant using filters requires attribute gymnastics or complex filter factories. The code becomes hard to follow and harder to test.
Why Middleware Wins for SaaS Authentication
Early Execution = Faster Failures
Authentication middleware runs before routing. Invalid token? Missing tenant header? Suspended account? Reject the request before it touches your business logic.
public class TenantAuthMiddleware
{
public async Task InvokeAsync(HttpContext context, RequestDelegate next)
{
var tenantId = context.Request.Headers["X-Tenant-ID"].FirstOrDefault();
if (string.IsNullOrEmpty(tenantId))
{
context.Response.StatusCode = 400;
await context.Response.WriteAsync("Missing tenant identifier");
return;
}
var tenant = await _tenantService.GetTenantAsync(tenantId);
if (tenant?.IsActive != true)
{
context.Response.StatusCode = 403;
return;
}
context.Items["TenantId"] = tenantId;
await next(context);
}
}
Global & Cross-Cutting Simplicity
One middleware handles auth for every request type in your application. API controllers, gRPC services, health endpoints, file uploads, everything gets the same consistent security treatment.
// Program.cs
app.UseMiddleware<TenantAuthMiddleware>();
app.MapControllers();
app.MapGrpcService<ProductService>();
app.MapHealthChecks("/health");
Cleaner Separation of Concerns
Controllers focus on business logic. Middleware handles cross-cutting concerns like security, tenant resolution, and request validation. Your controller actions become cleaner and easier to unit test.
[ApiController]
public class ProductsController : ControllerBase
{
public async Task<IActionResult> GetProducts()
{
// Tenant already resolved and validated by middleware
var tenantId = HttpContext.Items["TenantId"].ToString();
var products = await _productService.GetProductsAsync(tenantId);
return Ok(products);
}
}
Real-World Impact from Client Projects
Across multiple client-facing APIs, shifting authentication and tenant checks into custom middleware led to faster rejections, simpler error handling, and less cluttered controllers. In high-traffic internal APIs I’ve worked on, moving auth logic to middleware helped reject unauthorized or malformed requests 10–20ms earlier on average. While these weren’t massive-scale SaaS platforms, the pattern consistently improved maintainability and reduced overhead in multi-tenant or access-controlled endpoints.
Implementation Pattern
Register your middleware early in the pipeline, right after exception handling but before any business logic:
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.UseExceptionHandler();
app.UseMiddleware<TenantAuthMiddleware>();
app.UseRouting();
app.MapControllers();
References
- ASP.NET Core middleware vs filters - Stack Overflow
- How to use Middleware and Filters in a .NET Core Pipeline - FullStack