ASP.NET Core Middleware & Filters: A Deep Dive
Build custom middleware, action filters, exception filters, and result filters in ASP.NET Core — with real production patterns for global error handling, request logging, and response transformation.
Middleware vs Filters
Understanding when to use each:
| | Middleware | Filters |
|---|---|---|
| Scope | Every request (including static files, non-MVC) | Only MVC/API requests |
| Access to | Raw HttpContext | ActionContext, model binding result, IActionResult |
| Order | Determined by registration order in Program.cs | Determined by filter pipeline stages |
| Best for | CORS, auth, rate limiting, response compression, logging | Validation, model transformation, caching, audit |
Middleware
How the Pipeline Works
Request → Middleware 1 → Middleware 2 → Middleware 3 → Endpoint
Response ← ← ← ←Each middleware calls await next(context) to pass control to the next component. After next() returns, it handles the response.
Convention-Based Middleware
public class RequestTimingMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<RequestTimingMiddleware> _logger;
public RequestTimingMiddleware(RequestDelegate next, ILogger<RequestTimingMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
var sw = Stopwatch.StartNew();
// Before the next middleware
context.Response.OnStarting(() =>
{
context.Response.Headers["X-Response-Time"] = $"{sw.ElapsedMilliseconds}ms";
return Task.CompletedTask;
});
await _next(context);
sw.Stop();
_logger.LogInformation(
"{Method} {Path} → {StatusCode} in {Elapsed}ms",
context.Request.Method,
context.Request.Path,
context.Response.StatusCode,
sw.ElapsedMilliseconds);
}
}Registration:
// Program.cs
app.UseMiddleware<RequestTimingMiddleware>();
// Or extension method (preferred):
app.UseRequestTiming();public static class RequestTimingMiddlewareExtensions
{
public static IApplicationBuilder UseRequestTiming(this IApplicationBuilder app)
=> app.UseMiddleware<RequestTimingMiddleware>();
}IMiddleware (Factory-Activated)
Use IMiddleware when your middleware needs scoped services injected per request (regular convention-based middleware uses the singleton lifetime):
public class TenantResolutionMiddleware : IMiddleware
{
private readonly ITenantService _tenantService; // scoped service
public TenantResolutionMiddleware(ITenantService tenantService)
=> _tenantService = tenantService;
public async Task InvokeAsync(HttpContext context, RequestDelegate next)
{
var tenantHeader = context.Request.Headers["X-Tenant-Id"].FirstOrDefault();
if (!string.IsNullOrEmpty(tenantHeader))
{
var tenant = await _tenantService.ResolveAsync(tenantHeader);
context.Items["CurrentTenant"] = tenant;
}
await next(context);
}
}Registration:
// Must register as transient or scoped (not singleton)
builder.Services.AddTransient<TenantResolutionMiddleware>();
app.UseMiddleware<TenantResolutionMiddleware>();Global Exception Handling Middleware
The most important middleware — catches all unhandled exceptions and returns a structured error response:
public class GlobalExceptionHandlingMiddleware : IMiddleware
{
private readonly ILogger<GlobalExceptionHandlingMiddleware> _logger;
public GlobalExceptionHandlingMiddleware(ILogger<GlobalExceptionHandlingMiddleware> logger)
=> _logger = logger;
public async Task InvokeAsync(HttpContext context, RequestDelegate next)
{
try
{
await next(context);
}
catch (Exception ex)
{
await HandleExceptionAsync(context, ex);
}
}
private async Task HandleExceptionAsync(HttpContext context, Exception exception)
{
var (statusCode, title, detail) = exception switch
{
NotFoundException notFound => (
StatusCodes.Status404NotFound,
"Resource Not Found",
notFound.Message
),
ValidationException validation => (
StatusCodes.Status422UnprocessableEntity,
"Validation Failed",
string.Join("; ", validation.Errors.Select(e => e.ErrorMessage))
),
UnauthorizedAccessException => (
StatusCodes.Status401Unauthorized,
"Unauthorized",
"You are not authorized to access this resource."
),
ForbiddenAccessException => (
StatusCodes.Status403Forbidden,
"Forbidden",
"You do not have permission to perform this action."
),
_ => (
StatusCodes.Status500InternalServerError,
"Internal Server Error",
"An unexpected error occurred."
)
};
// Log server errors with full stack; client errors at warning
if (statusCode >= 500)
_logger.LogError(exception, "Unhandled exception: {Message}", exception.Message);
else
_logger.LogWarning(exception, "Handled exception: {Message}", exception.Message);
var problemDetails = new ProblemDetails
{
Status = statusCode,
Title = title,
Detail = detail,
Instance = context.Request.Path,
Extensions =
{
["traceId"] = Activity.Current?.Id ?? context.TraceIdentifier
}
};
context.Response.StatusCode = statusCode;
context.Response.ContentType = "application/problem+json";
await context.Response.WriteAsJsonAsync(problemDetails);
}
}// Program.cs — register FIRST so it catches everything
builder.Services.AddTransient<GlobalExceptionHandlingMiddleware>();
app.UseMiddleware<GlobalExceptionHandlingMiddleware>();Request/Response Logging Middleware
Logs request bodies and response bodies (for debugging, audit trails):
public class RequestResponseLoggingMiddleware : IMiddleware
{
private readonly ILogger<RequestResponseLoggingMiddleware> _logger;
private readonly RequestResponseLoggingOptions _options;
public RequestResponseLoggingMiddleware(
ILogger<RequestResponseLoggingMiddleware> logger,
IOptions<RequestResponseLoggingOptions> options)
{
_logger = logger;
_options = options.Value;
}
public async Task InvokeAsync(HttpContext context, RequestDelegate next)
{
if (!_options.Enabled)
{
await next(context);
return;
}
var requestBody = await ReadRequestBodyAsync(context.Request);
// Buffer the response so we can read it back
var originalBody = context.Response.Body;
using var responseBuffer = new MemoryStream();
context.Response.Body = responseBuffer;
await next(context);
responseBuffer.Seek(0, SeekOrigin.Begin);
var responseBody = await new StreamReader(responseBuffer).ReadToEndAsync();
_logger.LogDebug(
"HTTP {Method} {Path} | Body: {RequestBody} | Response {StatusCode}: {ResponseBody}",
context.Request.Method,
context.Request.Path,
requestBody,
context.Response.StatusCode,
responseBody[..Math.Min(responseBody.Length, 500)] // truncate
);
responseBuffer.Seek(0, SeekOrigin.Begin);
await responseBuffer.CopyToAsync(originalBody);
}
private static async Task<string> ReadRequestBodyAsync(HttpRequest request)
{
request.EnableBuffering();
using var reader = new StreamReader(request.Body, leaveOpen: true);
var body = await reader.ReadToEndAsync();
request.Body.Position = 0;
return body;
}
}Filters
Filters run inside the MVC pipeline, after routing has matched the endpoint. They have access to model binding results and can short-circuit before the action executes.
Filter Pipeline Order
Authorization Filters
→ Resource Filters (before model binding)
→ Action Filters (before/after action method)
→ Exception Filters (only on exceptions)
→ Result Filters (before/after IActionResult execution)Action Filter
// Validates the model state before the action executes
public class ValidateModelAttribute : ActionFilterAttribute
{
public override void OnActionExecuting(ActionExecutingContext context)
{
if (!context.ModelState.IsValid)
{
var errors = context.ModelState
.Where(x => x.Value?.Errors.Count > 0)
.ToDictionary(
k => k.Key,
v => v.Value!.Errors.Select(e => e.ErrorMessage).ToArray()
);
context.Result = new UnprocessableEntityObjectResult(new
{
title = "Validation Failed",
status = 422,
errors
});
}
}
}// Apply globally
builder.Services.AddControllers(options =>
options.Filters.Add<ValidateModelAttribute>()
);
// Or per controller / per action
[ValidateModel]
public class OrdersController : ControllerBase { ... }Asynchronous Action Filter
public class AuditLogFilter : IAsyncActionFilter
{
private readonly IAuditService _audit;
public AuditLogFilter(IAuditService audit) => _audit = audit;
public async Task OnActionExecutionAsync(
ActionExecutingContext context,
ActionExecutionDelegate next)
{
var started = DateTime.UtcNow;
var executed = await next(); // run the action
await _audit.LogAsync(new AuditEntry
{
UserId = context.HttpContext.User.FindFirst("sub")?.Value,
Action = context.ActionDescriptor.DisplayName,
Elapsed = (int)(DateTime.UtcNow - started).TotalMilliseconds,
StatusCode = context.HttpContext.Response.StatusCode,
Success = executed.Exception is null,
Timestamp = started
});
}
}Exception Filter
Handles exceptions thrown by action methods (but not by other middleware):
public class ApiExceptionFilter : IExceptionFilter
{
private readonly ILogger<ApiExceptionFilter> _logger;
public ApiExceptionFilter(ILogger<ApiExceptionFilter> logger) => _logger = logger;
public void OnException(ExceptionContext context)
{
if (context.Exception is NotFoundException notFound)
{
context.Result = new ObjectResult(new ProblemDetails
{
Status = 404,
Title = "Not Found",
Detail = notFound.Message
}) { StatusCode = 404 };
context.ExceptionHandled = true;
}
}
}Prefer global exception middleware over exception filters for most cases — middleware catches more (including exceptions from middleware and filters themselves).
Result Filter — Response Envelope
Wraps all API responses in a standard envelope:
public class ResponseEnvelopeFilter : IResultFilter
{
public void OnResultExecuting(ResultExecutingContext context)
{
if (context.Result is ObjectResult objectResult
&& context.HttpContext.Response.StatusCode < 400)
{
context.Result = new ObjectResult(new
{
success = true,
data = objectResult.Value,
timestamp = DateTime.UtcNow
})
{ StatusCode = objectResult.StatusCode };
}
}
public void OnResultExecuted(ResultExecutedContext context) { }
}Resource Filter — Response Caching
Resource filters run before model binding — ideal for short-circuiting with a cached response:
public class CacheResponseAttribute : Attribute, IResourceFilter
{
private readonly int _seconds;
private static readonly Dictionary<string, (DateTime Expiry, IActionResult Result)> _cache = new();
public CacheResponseAttribute(int seconds = 60) => _seconds = seconds;
public void OnResourceExecuting(ResourceExecutingContext context)
{
var key = context.HttpContext.Request.Path + context.HttpContext.Request.QueryString;
if (_cache.TryGetValue(key, out var cached) && cached.Expiry > DateTime.UtcNow)
{
context.Result = cached.Result; // short-circuit — skip action
}
}
public void OnResourceExecuted(ResourceExecutedContext context)
{
if (context.Result is ObjectResult result)
{
var key = context.HttpContext.Request.Path + context.HttpContext.Request.QueryString;
_cache[key] = (DateTime.UtcNow.AddSeconds(_seconds), result);
}
}
}[HttpGet("{id}")]
[CacheResponse(seconds: 30)]
public async Task<IActionResult> GetProduct(int id) { ... }Authorization Filter (Custom Policy)
public class RequireAdminFilter : IAuthorizationFilter
{
public void OnAuthorization(AuthorizationFilterContext context)
{
var user = context.HttpContext.User;
if (!user.Identity?.IsAuthenticated ?? true)
{
context.Result = new UnauthorizedResult();
return;
}
if (!user.IsInRole("Admin"))
{
context.Result = new ForbidResult();
}
}
}Putting It Together: Correct Order in Program.cs
Middleware order matters — requests flow through top to bottom, responses bottom to top:
var app = builder.Build();
// 1. Exception handling — must be first to catch all errors
app.UseMiddleware<GlobalExceptionHandlingMiddleware>();
// 2. Security headers
app.UseHsts();
app.UseHttpsRedirection();
// 3. Static files — no auth required
app.UseStaticFiles();
// 4. Routing
app.UseRouting();
// 5. CORS — before auth
app.UseCors("AllowFrontend");
// 6. Auth
app.UseAuthentication();
app.UseAuthorization();
// 7. Custom middleware that needs auth context
app.UseMiddleware<TenantResolutionMiddleware>();
app.UseRequestTiming();
// 8. Endpoints
app.MapControllers();
app.MapHealthChecks("/health");Key Takeaways
- Middleware runs for every request; filters run only inside the MVC pipeline
- Use
IMiddleware(factory-activated) when you need scoped services — regular convention middleware is a singleton - Global exception handling middleware belongs first in the pipeline
ActionFilterAttributeis easiest for synchronous filters;IAsyncActionFilterfor async work- Exception filters only catch exceptions from action methods — exception middleware catches everything
- Set
context.ExceptionHandled = truein exception filters to suppress re-throwing - Resource filters run before model binding — the right place for response caching
- Middleware order in
Program.csis the most common source of auth bugs — routing before CORS before auth is the correct sequence
Enjoyed this article?
Explore the Backend Systems learning path for more.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.