On this page
Middleware and filters in ASP.NET Core
The middleware pipeline
In ASP.NET Core, every HTTP request passes through a chain of middleware. Each middleware decides whether to process the request and whether to pass it to the next:
Request ──→ Middleware 1 ──→ Middleware 2 ──→ ... ──→ Endpoint
↑ ↑ ↑
└── next() ─────────┘── next() ─────────────┘
Response ←─── Middleware 1 ←─── Middleware 2 ←─── ... ←─── EndpointRecommended order in ASP.NET Core:
var app = builder.Build();
// 1. Exception handling — FIRST
app.UseExceptionHandler("/error");
app.UseHsts();
// 2. HTTPS redirect
app.UseHttpsRedirection();
// 3. Static files
app.UseStaticFiles();
// 4. Routing
app.UseRouting();
// 5. CORS
app.UseCors("PolicyName");
// 6. Authentication → Authorization
app.UseAuthentication();
app.UseAuthorization();
// 7. Endpoints — LAST
app.MapControllers();
app.Run();Types of middleware
Use — chained (most common)
app.Use(async (context, next) =>
{
// Before the next layer
Console.WriteLine($"Before: {context.Request.Path}");
await next.Invoke(context); // pass to next
// After the next layer
Console.WriteLine($"After: {context.Response.StatusCode}");
});Run — terminal (does not chain)
// Intercepts ALL requests that reach this point
app.Run(async context =>
{
context.Response.StatusCode = 404;
await context.Response.WriteAsync("Route not found");
});Map — branch by route
// Only processes requests to /health
app.Map("/health", branch =>
{
branch.Run(async ctx =>
{
ctx.Response.ContentType = "application/json";
await ctx.Response.WriteAsJsonAsync(new { status = "healthy" });
});
});Custom middleware with a class
The recommended approach for middleware that needs dependency injection:
// Option 1: IMiddleware (cleaner, constructor DI)
public class RateLimitMiddleware : IMiddleware
{
private static readonly Dictionary<string, (int Count, DateTime Reset)> _hits = new();
private readonly ILogger<RateLimitMiddleware> _logger;
public RateLimitMiddleware(ILogger<RateLimitMiddleware> logger)
=> _logger = logger;
public async Task InvokeAsync(HttpContext ctx, RequestDelegate next)
{
var ip = ctx.Connection.RemoteIpAddress?.ToString() ?? "unknown";
var now = DateTime.UtcNow;
var window = TimeSpan.FromMinutes(1);
if (_hits.TryGetValue(ip, out var state))
{
if (now < state.Reset && state.Count >= 60) // 60 req/min
{
_logger.LogWarning("Rate limit exceeded for IP {Ip}", ip);
ctx.Response.StatusCode = 429; // Too Many Requests
ctx.Response.Headers["Retry-After"] = "60";
await ctx.Response.WriteAsJsonAsync(new { error = "Too many requests" });
return;
}
if (now >= state.Reset)
_hits[ip] = (1, now.Add(window));
else
_hits[ip] = (state.Count + 1, state.Reset);
}
else
{
_hits[ip] = (1, now.Add(window));
}
await next(ctx);
}
}
// Option 2: Convention-based (InvokeAsync with DI parameter)
public class CorrelationIdMiddleware
{
private readonly RequestDelegate _next;
public CorrelationIdMiddleware(RequestDelegate next) => _next = next;
public async Task InvokeAsync(HttpContext ctx)
{
// DI via InvokeAsync parameter
var correlationId = ctx.Request.Headers["X-Correlation-Id"]
.FirstOrDefault() ?? Guid.NewGuid().ToString();
ctx.Items["CorrelationId"] = correlationId;
ctx.Response.Headers["X-Correlation-Id"] = correlationId;
await _next(ctx);
}
}Registering and using middleware
// Register in the DI container
builder.Services.AddTransient<RateLimitMiddleware>();
builder.Services.AddTransient<LoggingMiddleware>();
builder.Services.AddTransient<ExceptionMiddleware>();
// Extension method for clean usage
public static class MiddlewareExtensions
{
public static IApplicationBuilder UseRateLimit(this IApplicationBuilder app)
=> app.UseMiddleware<RateLimitMiddleware>();
public static IApplicationBuilder UseRequestLogging(this IApplicationBuilder app)
=> app.UseMiddleware<LoggingMiddleware>();
}
// In Program.cs
app.UseMiddleware<ExceptionMiddleware>(); // first
app.UseRequestLogging();
app.UseRateLimit();Action filters
Filters are middleware specific to the MVC/controllers pipeline:
| Type | When it runs |
|---|---|
IAuthorizationFilter |
Before the action — decides whether to proceed |
IResourceFilter |
After routing, before model binding |
IActionFilter |
Just before/after the action |
IResultFilter |
Before/after the result is written |
IExceptionFilter |
When there is an unhandled exception |
// API Key validation filter as an attribute
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
public class ApiKeyAttribute : Attribute, IAuthorizationFilter
{
public void OnAuthorization(AuthorizationFilterContext context)
{
if (!context.HttpContext.Request.Headers.TryGetValue("X-API-Key", out var key))
{
context.Result = new UnauthorizedObjectResult(
new { error = "API Key required" });
return;
}
var expectedKey = context.HttpContext.RequestServices
.GetRequiredService<IConfiguration>()["ApiKey"];
if (key != expectedKey)
context.Result = new ForbidResult();
}
}
// Usage
[ApiKey]
[HttpGet("private-data")]
public IActionResult GetPrivateData() => Ok("Only with API Key");Global exception handling with ProblemDetails
// ASP.NET Core 7+ — UseExceptionHandler with ProblemDetails
builder.Services.AddProblemDetails();
app.UseExceptionHandler(errorApp =>
{
errorApp.Run(async ctx =>
{
var exceptionHandlerFeature = ctx.Features
.Get<Microsoft.AspNetCore.Diagnostics.IExceptionHandlerFeature>();
var exception = exceptionHandlerFeature?.Error;
ctx.Response.StatusCode = exception switch
{
ArgumentException => 400,
UnauthorizedAccessException => 401,
KeyNotFoundException => 404,
_ => 500
};
await ctx.Response.WriteAsJsonAsync(new ProblemDetails
{
Title = "An error occurred",
Detail = exception?.Message,
Status = ctx.Response.StatusCode,
Instance = ctx.Request.Path
});
});
});Practice
- Logging middleware: Implement
RequestLoggingMiddlewarethat logs method, path, response code, and execution time usingILogger. - Correlation filter: Create an
ActionFilterthat adds anX-Request-Idheader with a unique GUID to every response. - Exception handling: Implement an
ExceptionMiddlewarethat convertsNotFoundExceptionto 404,ValidationExceptionto 400, and anything else to 500 with a consistent JSON body.
In the next lesson we will learn how to write tests in .NET with xUnit, Moq, and WebApplicationFactory for APIs.
Pipeline order matters
Middleware executes in the order it is registered with app.Use*. Exception handling MUST be first (to catch errors from any subsequent middleware). Authentication MUST come before Authorization. UseRouting MUST come before UseAuthorization and UseEndpoints.
IMiddleware vs inline middleware
IMiddleware allows constructor dependency injection and is easier to test. Inline middleware (app.Use((ctx, next) => ...)) is useful for simple one-or-two-line logic. For middleware that needs services (logging, DB), always use IMiddleware.
Do not forget to register IMiddleware in DI
Unlike inline middleware, IMiddleware must be registered in the DI container. Add builder.Services.AddTransient<LoggingMiddleware>() before builder.Build(). If you forget the registration you will get an InvalidOperationException at startup.
// ── Middleware with IMiddleware (DI-friendly) ─────────
public class LoggingMiddleware : IMiddleware
{
private readonly ILogger<LoggingMiddleware> _logger;
public LoggingMiddleware(ILogger<LoggingMiddleware> logger)
=> _logger = logger;
public async Task InvokeAsync(HttpContext context, RequestDelegate next)
{
var sw = System.Diagnostics.Stopwatch.StartNew();
var method = context.Request.Method;
var path = context.Request.Path;
_logger.LogInformation("→ {Method} {Path}", method, path);
try
{
await next(context); // call the next middleware
}
finally
{
sw.Stop();
_logger.LogInformation(
"← {Method} {Path} {Status} ({Ms}ms)",
method, path,
context.Response.StatusCode,
sw.ElapsedMilliseconds);
}
}
}
// ── Global exception handling middleware ──────────────
public class ExceptionMiddleware : IMiddleware
{
private readonly ILogger<ExceptionMiddleware> _logger;
public ExceptionMiddleware(ILogger<ExceptionMiddleware> logger)
=> _logger = logger;
public async Task InvokeAsync(HttpContext ctx, RequestDelegate next)
{
try
{
await next(ctx);
}
catch (Exception ex)
{
_logger.LogError(ex, "Unhandled error");
ctx.Response.StatusCode = 500;
ctx.Response.ContentType = "application/json";
await ctx.Response.WriteAsJsonAsync(new
{
error = "Internal server error",
traceId = ctx.TraceIdentifier
});
}
}
}
Sign in to track your progress