All articles

ASP.NET Core MVC Filters: Built-in and Custom with Examples

The five filter stages, the built-in attributes you get for free, and custom action, exception, and result filters with dependency injection — verified outputs included.

0 · log in to like, save & follow Share on LinkedIn Share on X
ASP.NET Core MVC Filters: Built-in and Custom with Examples

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.

ASP.NET Core MVC Filters: Built-in and Custom with Examples

The filter pipeline

Filters run in a fixed order, nested inside the middleware pipeline:

  1. Authorization filters — first; short-circuit unauthenticated requests ([Authorize]).
  2. Resource filters — around everything that follows, including model binding.
  3. Action filters — immediately around the action method.
  4. Exception filters — catch unhandled exceptions from binding, the action, or page execution.
  5. 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-wide UseHttpsRedirection is 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.

Enjoyed this article? Get the best GeeksArray articles in your inbox — once a week, no spam, unsubscribe anytime.

Comments (0)

Log in to join the conversation.

No comments yet — be the first to share your thoughts.