MVC filters wrap defined stages of controller execution. They fit cross-cutting behavior that needs action arguments, model state, controller metadata, or the action result. A correlation-ID rule tied only to selected controllers is one example. An application-wide correlation ID usually belongs in middleware.
The boundary matters. Middleware can cover every request type but lacks MVC action context. Endpoint code has full local context but duplicates a rule when many actions need it. Filters occupy the space between those two.
Filters run after routing has selected an MVC action.
- Authorization filters run first and can short-circuit unauthorized requests.
- Resource filters run around most of the rest of the pipeline and can short-circuit early.
- Action filters run before and after the action method.
- Exception filters observe unhandled exceptions from action execution.
- Result filters run before and after the action result is executed.
Scope and filter type both affect nesting. IOrderedFilter.Order can override the default scope order, so a custom order should be treated as part of the endpoint’s behavior.
Example
This async action filter rejects selected requests without a correlation header:
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
public sealed class RequireCorrelationIdFilter : IAsyncActionFilter
{
public async Task OnActionExecutionAsync(
ActionExecutingContext context,
ActionExecutionDelegate next)
{
if (!context.HttpContext.Request.Headers.ContainsKey("X-Correlation-Id"))
{
context.Result = new BadRequestObjectResult(new
{
error = "X-Correlation-Id header is required"
});
return;
}
await next();
}
}
// Program.cs
builder.Services.AddScoped<RequireCorrelationIdFilter>();
builder.Services.AddControllers(options =>
{
// Global registration
options.Filters.AddService<RequireCorrelationIdFilter>();
});For one action or controller, [ServiceFilter(typeof(RequireCorrelationIdFilter))] applies the registered filter without making it global.
An exception filter can translate an unhandled exception from action execution into an MVC result:
public sealed class ApiExceptionFilter(ILogger<ApiExceptionFilter> logger) : IAsyncExceptionFilter
{
public Task OnExceptionAsync(ExceptionContext context)
{
logger.LogError(context.Exception, "Unhandled exception in {Action}",
context.ActionDescriptor.DisplayName);
context.Result = context.Exception is NotFoundException
? new NotFoundObjectResult(new { error = context.Exception.Message })
: new ObjectResult(new { error = "An unexpected error occurred." }) { StatusCode = 500 };
context.ExceptionHandled = true;
return Task.CompletedTask;
}
}Global registration applies it to every MVC action: builder.Services.AddControllers(opts => opts.Filters.Add<ApiExceptionFilter>());. Middleware remains the broader choice when errors from routing or non-MVC endpoints need the same response contract.
Applying Filters: [ServiceFilter] Vs [TypeFilter] Vs IFilterFactory
Attributes cannot receive runtime services through their own constructors, so attachment determines how the real filter instance is created.
[ServiceFilter(typeof(MyFilter))]resolves the filter itself from DI, soMyFiltermust be registered with the intended lifetime.[TypeFilter(typeof(MyFilter))]usesActivatorUtilities. Constructor services come from DI, whileArgumentscan supply literal values. The filter type itself does not need registration.IFilterFactorygives an attribute explicit control over creation of the executable filter and whether the result can be reused.- A plain filter attribute can carry literal metadata but cannot accept runtime services in its attribute constructor.
[Authorize] contributes authorization metadata that MVC handles at its authorization stage, so duplicating policy checks in an action filter creates two access-control paths. Within a filter type, the usual nesting is global, controller, then action on the way in and the reverse on the way out. If a filter implements both sync and async forms of the same stage, the runtime invokes only the async interface. Implement one form.
Pitfalls
- Blocking I/O:
.Resultor synchronous remote calls hold request threads while no CPU work is happening. I/O-bound filters should implement the async interface and await the operation. - Authorization in action filters: a second permission system drifts away from registered policies. Use authentication handlers and authorization policies for access decisions.
- Treating exception filters as global error handling: exception filters cover unhandled exceptions from controller creation, model binding, action filters, and action methods. They do not cover middleware, routing, resource filters, result filters, or MVC result execution. Exception-handling middleware is the safer outer boundary for a uniform API error contract.
Tradeoffs
| Option | Best for | Weakness |
|---|---|---|
| Middleware | App-wide cross-cutting concerns (logging, auth, exception handling) | No direct MVC action context |
| MVC filters | Concerns tied to controllers/actions and model/action context | Only applies to MVC pipeline |
| Endpoint filters | Minimal API endpoint-scoped behavior | Not used by MVC controllers |
Questions
What is the execution order of ASP.NET Core filter types?
Authorization filters run first. Resource filters then wrap the rest of the MVC pipeline, action filters wrap the action, and result filters wrap execution of the selected result. The normal before path is therefore authorization, resource, action, then result, with the wrapping filters running their after logic in reverse.
Exception filters are conditional, not another before-and-after stage. They run only when an unhandled exception comes from controller creation, model binding, an action filter, or the action method. They do not catch failures from authorization filters, resource filters, result filters, or result execution. If an exception filter handles the failure and supplies a result, result processing continues; otherwise the exception leaves the MVC pipeline.
Within one filter type, lower
IOrderedFilter.Ordervalues run earlier on the way in and later on the way out. When order values are equal, scope normally nests global, controller, then action.