Filters are MVC's interception points: small classes that run before and after specific stages of action execution. Logging, caching headers, exception shaping, authorization — anything you'd otherwise copy into every action belongs in a filter. This guide walks the filter pipeline on .NET 10, shows the built-in attributes you get for free, and builds three custom filters (action, exception, result) with verified output from the companion repository.

The filter pipeline
Filters run in a fixed order, nested inside the middleware pipeline:
- Authorization filters — first; short-circuit unauthenticated requests (
[Authorize]). - Resource filters — around everything that follows, including model binding.
- Action filters — immediately around the action method.
- Exception filters — catch unhandled exceptions from binding, the action, or page execution.
- Result filters — around result execution (view rendering, JSON serialization).
Each stage sees the request on the way in and the outcome on the way out — an action filter's OnActionExecuting runs after model binding (so it can inspect arguments), and OnActionExecuted sees the returned IActionResult before it executes.
The built-in filters you already have
[Authorize]/[AllowAnonymous]— authorization stage; policy- and role-aware.[ValidateAntiForgeryToken]/[AutoValidateAntiforgeryToken]— reject cross-site form posts.[ResponseCache]— emits caching headers:
[HttpGet("/products/{id:int}", Name = "product-detail")]
[ResponseCache(Duration = 60, Location = ResponseCacheLocation.Client)]
public IActionResult Detail(int id) { /* ... */ }
GET /products/2
HTTP/1.1 200 OK
Cache-Control: private,max-age=60
[RequireHttps]— redirect HTTP to HTTPS at the action level (app-wideUseHttpsRedirectionis usually better).[TypeFilter]/[ServiceFilter]— attach filters that need constructor-injected dependencies (below).
A custom action filter
public class AuditActionFilter(ILogger<AuditActionFilter> logger) : IActionFilter
{
public void OnActionExecuting(ActionExecutingContext context) =>
logger.LogInformation("[audit] executing {Action} args={Args}",
context.ActionDescriptor.DisplayName,
string.Join(",", context.ActionArguments.Select(a => $"{a.Key}={a.Value}")));
public void OnActionExecuted(ActionExecutedContext context) =>
logger.LogInformation("[audit] executed {Action} result={Result}",
context.ActionDescriptor.DisplayName, context.Result?.GetType().Name);
}
Because it takes an ILogger from DI, it can't be applied as a plain attribute. Register it and attach with ServiceFilter:
builder.Services.AddScoped<AuditActionFilter>();
[ServiceFilter<AuditActionFilter>]
public class ProductsController : Controller { /* every action is audited */ }
Verified log:
[audit] executing ProductsController.Boom args=
[audit] executed ProductsController.Boom result=(null)
ServiceFilter resolves the filter from the container (you control the lifetime); TypeFilter news it up per request, injecting dependencies without registration. For filters with no dependencies at all, derive from ActionFilterAttribute and use it directly as an attribute.
A custom exception filter
public class DemoExceptionFilter(ILogger<DemoExceptionFilter> logger) : IExceptionFilter
{
public void OnException(ExceptionContext context)
{
logger.LogError(context.Exception, "[exception-filter] {Message}", context.Exception.Message);
context.Result = new ObjectResult(new ProblemDetails
{
Title = "Something went wrong handling your request.",
Status = StatusCodes.Status500InternalServerError,
Detail = context.Exception.Message,
})
{ StatusCode = StatusCodes.Status500InternalServerError };
context.ExceptionHandled = true;
}
}
Applied with [TypeFilter<DemoExceptionFilter>] to an action that throws:
GET /Products/Boom
HTTP 500
{"title":"Something went wrong handling your request.","status":500,
"detail":"Demo failure inside an action."}
Setting ExceptionHandled = true stops propagation — the middleware-level exception handler never sees it. That is the decision point: use an exception filter for MVC-specific shaping (ProblemDetails for API controllers, a friendly view for pages) and the UseExceptionHandler middleware for the global safety net. Filters can't catch exceptions thrown in middleware or in result execution of other filters; middleware catches everything.
A custom result filter
public class ServerStampResultFilter : IResultFilter
{
public void OnResultExecuting(ResultExecutingContext context) =>
context.HttpContext.Response.Headers["X-Served-By"] = "AspNetCoreMvcExamples";
public void OnResultExecuted(ResultExecutedContext context) { }
}
Registered globally, so every MVC response carries the header:
builder.Services.AddControllersWithViews(options =>
{
options.Filters.Add<ServerStampResultFilter>();
});
GET /Products
X-Served-By: AspNetCoreMvcExamples
OnResultExecuting is your last chance to touch headers — once the view starts writing to the body, the response has begun and header changes throw.
Scope and ordering
Filters attach at three scopes, and all of them run: global (in AddControllersWithViews), controller, and action. Within a stage, execution goes global → controller → action on the way in, and unwinds in reverse on the way out. When you need explicit control, implement IOrderedFilter — lower Order runs first.
Prefer IAsyncActionFilter (one OnActionExecutionAsync with an await next()) when the filter itself does I/O; the sync pair is fine for logging and header work.
Filters vs. middleware
The rule of thumb: if the logic needs MVC concepts — action arguments, ModelState, the IActionResult — it's a filter. If it applies to every request including static files and non-MVC endpoints, it's middleware. Rate limiting is middleware; auditing action arguments is a filter; response compression is middleware; ProblemDetails shaping for controllers is an exception filter.
Summary
The filter pipeline gives you five interception stages with a predictable order. Use the built-ins ([Authorize], [ResponseCache], anti-forgery) before writing your own; when you do write one, pick the narrowest stage that sees what you need, inject dependencies via ServiceFilter/TypeFilter, and register globally only what genuinely applies everywhere. The companion repository contains all three custom filters with the exact outputs shown here.
Comments (0)
No comments yet — be the first to share your thoughts.