In modern web development, Cross-Origin Resource Sharing (CORS) is a fundamental security mechanism. For standard, single-client applications, configuring a CORS policy in ASP.NET Core is straightforward: you define a set of allowed origins in your Program.cs. But what happens when you’re building a multi-tenant application where each tenant needs a different set of allowed origins?
A static, hardcoded list of origins quickly becomes a bottleneck. Adding a new tenant or updating a tenant’s domain would require a code change and a redeployment. This is not scalable or secure. The solution is to create a dynamic, per-tenant CORS policy that resolves the correct origins at runtime.
In this post, we’ll build a custom ASP.NET Core middleware to achieve exactly that. We’ll create a flexible system that looks up a tenant’s specific CORS configuration on the fly for each incoming request.
The Problem with Static CORS in Multi-Tenant Architectures
Let’s quickly review the standard approach. In a typical Program.cs, you might see this:
builder.Services.AddCors(options =>
{
options.AddDefaultPolicy(policy =>
{
policy.WithOrigins("[https://client-app-one.com](https://client-app-one.com)", "[https://client-app-two.com](https://client-app-two.com)")
.AllowAnyHeader()
.AllowAnyMethod();
});
});
// ... in the pipeline configuration
app.UseCors();
This works perfectly for a predictable set of clients. In a multi-tenant SaaS, however, you could have hundreds or thousands of tenants, each with their own domains stored in a database. The static WithOrigins method simply can’t handle this dynamic reality.
Solution: A Custom Tenant-Aware CORS Middleware
Our goal is to create a piece of middleware that sits early in the request pipeline. It will:
- Identify the current tenant from the incoming request (e.g., via a header or hostname).
- Query a data source (like a database) for that tenant’s allowed CORS origins.
- Build a
CorsPolicyobject dynamically with those origins. - Apply the policy to the current request.
Let’s get building.
1. Tenant Resolution and Data Services
First, we need a way to identify the tenant and retrieve their CORS configuration. For this example, we’ll use simple, in-memory services, but you can easily adapt these to use a database like Entity Framework Core.
Tenant Service: This service will resolve a tenant ID from a request header, X-Tenant-ID.
// Abstraction
public interface ITenantService
{
string? GetTenantId();
}
// Implementation
public class HttpHeaderTenantService(IHttpContextAccessor httpContextAccessor) : ITenantService
{
public string? GetTenantId()
{
return httpContextAccessor.HttpContext?.Request.Headers["X-Tenant-ID"].FirstOrDefault();
}
}
CORS Repository: This service will fetch the allowed origins for a given tenant ID.
// Abstraction
public interface ITenantCorsRepository
{
Task<List<string>> GetAllowedOriginsAsync(string tenantId);
}
// In-memory implementation for demonstration
public class InMemoryTenantCorsRepository : ITenantCorsRepository
{
private readonly Dictionary<string, List<string>> _tenantOrigins = new()
{
["tenant-1"] = ["[https://tenant-a.com](https://tenant-a.com)", "[https://app.tenant-a.com](https://app.tenant-a.com)"],
["tenant-2"] = ["[https://tenant-b.com](https://tenant-b.com)"]
};
public Task<List<string>> GetAllowedOriginsAsync(string tenantId)
{
var origins = _tenantOrigins.GetValueOrDefault(tenantId, []);
return Task.FromResult(origins);
}
}
Callout: Caching is Key In a production environment, you should aggressively cache the results from your
ITenantCorsRepository. A tenant’s allowed origins don’t change often. Hitting the database for every single API request, especially for OPTIONS preflight requests, would introduce significant performance overhead. A distributed cache like Redis or even a simple in-memory cache would be a great addition here.
2. The Dynamic CORS Middleware
This is where the magic happens. Our middleware will use the services we just defined to construct and apply the CORS policy.
using Microsoft.AspNetCore.Cors.Infrastructure;
public class TenantCorsMiddleware(RequestDelegate next)
{
public async Task InvokeAsync(
HttpContext context,
ITenantService tenantService,
ITenantCorsRepository corsRepository)
{
var tenantId = tenantService.GetTenantId();
if (string.IsNullOrEmpty(tenantId))
{
// No tenant identified, proceed without a dynamic CORS policy
await next(context);
return;
}
var allowedOrigins = await corsRepository.GetAllowedOriginsAsync(tenantId);
if (allowedOrigins.Count == 0)
{
// Tenant has no configured origins, proceed
await next(context);
return;
}
// Dynamically create a CORS policy for the current tenant
var corsPolicy = new CorsPolicyBuilder()
.WithOrigins([.. allowedOrigins]) // Using collection expression
.AllowAnyHeader()
.AllowAnyMethod()
.Build();
// Get the core CORS services from DI
var corsService = context.RequestServices.GetRequiredService<ICorsService>();
// Evaluate the policy
var result = corsService.EvaluatePolicy(context, corsPolicy);
// Apply the results to the response headers
corsService.ApplyResult(result, context.Response);
// Handle preflight requests
if (context.Request.Method.Equals("OPTIONS", StringComparison.OrdinalIgnoreCase))
{
// Preflight request is handled, short-circuit the pipeline
context.Response.StatusCode = StatusCodes.Status204NoContent;
return;
}
// It's not a preflight, so continue to the next middleware
await next(context);
}
}
Let’s break down the InvokeAsync method:
- We resolve the
tenantIdusing ourITenantService. If none is found, we simply pass the request along the pipeline. - We fetch the
allowedOriginsfor that tenant. - Using
CorsPolicyBuilder, we construct a newCorsPolicyinstance on the fly, populating it with the origins we just fetched. - We get the
ICorsServicefrom the dependency injection container. This is the underlying service that the built-in ASP.NET Core CORS middleware uses. Reusing it ensures we follow the framework’s own logic for policy evaluation. corsService.EvaluatePolicychecks if the request’sOriginheader matches our dynamic policy.corsService.ApplyResultadds the appropriateAccess-Control-Allow-*headers to the response if the policy evaluation was successful.- Crucially, we check if the request is an
OPTIONS(preflight) request. If it is, and the policy has been successfully applied, we terminate the request with a204 No Contentresponse. This is the standard behavior and prevents the request from continuing unnecessarily to your controllers. - If it’s a regular request (like a
GETorPOST), we call_next(context)to continue processing.
3. Wiring It Up in Program.cs
Finally, we need to register our services and add the middleware to the pipeline.
var builder = WebApplication.CreateBuilder(args);
// 1. Add services needed for our implementation
builder.Services.AddHttpContextAccessor();
builder.Services.AddSingleton<ITenantService, HttpHeaderTenantService>();
builder.Services.AddSingleton<ITenantCorsRepository, InMemoryTenantCorsRepository>();
// 2. Add the core CORS services to DI, but without a default policy
builder.Services.AddCors();
builder.Services.AddControllers();
var app = builder.Build();
// 3. Add our custom middleware to the pipeline.
// Place it early, before routing and authentication.
app.UseMiddleware<TenantCorsMiddleware>();
// We do NOT call app.UseCors() because our middleware handles everything.
app.UseRouting();
app.UseAuthorization();
app.MapControllers();
app.Run();
Notice we still call builder.Services.AddCors(). This is important because it registers the necessary internal services like ICorsService that our middleware depends on. However, we do not call app.UseCors() in the pipeline configuration, as our TenantCorsMiddleware has now taken over that responsibility.
Testing the Implementation
You can test this setup using a tool like Postman or curl.
Scenario 1: Successful Request from an Allowed Origin
Send a preflight OPTIONS request for tenant-1 from an allowed origin.
curl -i -X OPTIONS "http://localhost:5000/api/values" \
-H "Access-Control-Request-Method: GET" \
-H "Origin: [https://tenant-a.com](https://tenant-a.com)" \
-H "X-Tenant-ID: tenant-1"
Expected Response: You should receive a 204 No Content status with the correct Access-Control-Allow-Origin header.
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: [https://tenant-a.com](https://tenant-a.com)
Access-Control-Allow-Headers: *
Access-Control-Allow-Methods: *
Scenario 2: Failed Request from a Disallowed Origin
Now, send the same request but from an origin that is not configured for tenant-1.
curl -i -X OPTIONS "http://localhost:5000/api/values" \
-H "Access-Control-Request-Method: GET" \
-H "Origin: [https://some-other-domain.com](https://some-other-domain.com)" \
-H "X-Tenant-ID: tenant-1"
Expected Response: The response will not contain any Access-Control-* headers, and the browser will block the subsequent request, effectively enforcing your per-tenant policy.
Conclusion
By creating a simple, targeted piece of middleware, we’ve replaced ASP.NET Core’s static CORS configuration with a dynamic, per-tenant system that is scalable, maintainable, and secure. This approach allows you to manage tenant-specific API access rules from a database or configuration provider without ever needing to redeploy your application. It’s a powerful pattern for any multi-tenant SaaS application built on ASP.NET Core.