Skip to content

Filters in ASP.NET Core: What They Are and How They Work

In a real application, many controller actions need the same extra work: check an API key, log how long the action took, validate input, handle errors in one place, add a response header. Writing that code inside every action makes controllers long and repetitive.

Filters solve this. A filter is a class that runs before and/or after a specific stage of an MVC or Web API request. You write the logic once and apply it to one action, a whole controller, or the entire application.

Think of an airport. Before you board (the action), you pass check-in, security and boarding-pass scanning. After landing, you pass baggage claim and customs. Each checkpoint has one job, and every passenger goes through them in a fixed order. Filters are those checkpoints around your controller actions.

This topic has three lessons:

  1. This lesson: what filters are, the filter types and how the pipeline works.
  2. Custom Authorization, Resource and Action filters with real examples.
  3. Custom Exception and Result filters, plus dependency injection and ordering.

Why use filters?

Filters handle cross-cutting concerns: logic that is needed by many actions but is not the main job of any of them.

  • Security: authorization, API key checks, IP allow-lists
  • Logging and auditing: who called which action, and how long it took
  • Validation: rejecting bad input before the action runs
  • Error handling: turning exceptions into a consistent JSON error response
  • Caching: returning a stored response without running the action
  • Response shaping: adding headers or wrapping results in a standard format

Without filters, every action would repeat this code:

[HttpGet("{id}")]
public async Task<IActionResult> GetProduct(int id)
{
    var watch = Stopwatch.StartNew();                                   // logging
    if (Request.Headers["X-Api-Key"] != "secret") return Unauthorized(); // security
    if (id <= 0) return BadRequest("Invalid id");                       // validation
    try
    {
        var product = await _service.GetAsync(id);                      // the REAL work: 1 line
        return Ok(product);
    }
    catch (Exception ex)                                                // error handling
    {
        _logger.LogError(ex, "Failed");
        return StatusCode(500, "Something went wrong");
    }
    finally
    {
        _logger.LogInformation("Took {Ms} ms", watch.ElapsedMilliseconds);
    }
}

With filters, the action contains only the real work:

[HttpGet("{id}")]
[ApiKey]                 // authorization filter
[ValidateId("id")]       // action filter
public async Task<IActionResult> GetProduct(int id)
{
    return Ok(await _service.GetAsync(id));
}

Timing, logging and error handling are registered once, globally, for all actions. We build every one of these filters in the next two lessons.

Where filters run: the filter pipeline

When a request arrives, it first passes through the middleware pipeline (HTTPS redirection, static files, routing, authentication...). Once routing has chosen a controller action, MVC runs the filter pipeline around that action:

Request
  │
  ▼
Middleware pipeline (UseRouting, UseAuthentication, UseAuthorization ...)
  │
  ▼  ── MVC filter pipeline starts ─────────────────────────────────────────
  │
  ├─► 1. Authorization filters    Is the user allowed?           (can stop here)
  │
  ├─► 2. Resource filters (before) e.g. return cached response   (can stop here)
  │
  │        Model binding + model validation
  │
  ├─► 3. Action filters (before)  e.g. validate arguments        (can stop here)
  │         │
  │         ▼
  │       ACTION METHOD RUNS
  │         │
  ├─◄ 3. Action filters (after)   e.g. log the time taken
  │
  │   4. Exception filters        only if an exception was thrown above
  │
  ├─► 5. Result filters (before)  e.g. add a header
  │         │
  │         ▼
  │       RESULT EXECUTES (view rendered / JSON written)
  │         │
  ├─◄ 5. Result filters (after)
  │
  └─◄ 2. Resource filters (after) e.g. store response in cache
  │
  ▼
Response

The five filter types

Filter typeInterfaceRunsTypical use
AuthorizationIAuthorizationFilter / IAsyncAuthorizationFilterFirst of all filtersDecide if the user may access the action; API key checks
ResourceIResourceFilter / IAsyncResourceFilterAfter authorization, before model binding; and at the very endCaching, short-circuiting most of the pipeline, request size limits
ActionIActionFilter / IAsyncActionFilterImmediately before and after the action methodValidate or change action arguments, logging, timing
ExceptionIExceptionFilter / IAsyncExceptionFilterWhen an unhandled exception occurs in controller creation, model binding, action filters or the actionConsistent error responses
ResultIResultFilter / IAsyncResultFilterImmediately before and after the action result executesAdd headers, wrap or format responses

Minimal APIs (app.MapGet(...)) don't use these MVC filters. Since .NET 7 they have their own endpoint filters (IEndpointFilter, added with .AddEndpointFilter(...)). This lesson series covers MVC and Web API controllers.

See the order for yourself

This demo filter implements all five interfaces and writes a line at every stage. Put it on any action and watch the console:

using Microsoft.AspNetCore.Mvc.Filters;

public class TraceFilterAttribute : Attribute,
    IAuthorizationFilter, IResourceFilter, IActionFilter, IExceptionFilter, IResultFilter
{
    public void OnAuthorization(AuthorizationFilterContext context)   => Console.WriteLine("1. Authorization");
    public void OnResourceExecuting(ResourceExecutingContext context) => Console.WriteLine("2. Resource - before");
    public void OnActionExecuting(ActionExecutingContext context)     => Console.WriteLine("3. Action - before");
    public void OnActionExecuted(ActionExecutedContext context)       => Console.WriteLine("5. Action - after");
    public void OnException(ExceptionContext context)                 => Console.WriteLine("   Exception filter");
    public void OnResultExecuting(ResultExecutingContext context)     => Console.WriteLine("6. Result - before");
    public void OnResultExecuted(ResultExecutedContext context)       => Console.WriteLine("7. Result - after");
    public void OnResourceExecuted(ResourceExecutedContext context)   => Console.WriteLine("8. Resource - after");
}

public class HomeController : Controller
{
    [TraceFilter]
    public IActionResult Index()
    {
        Console.WriteLine("4. Action method");
        return Content("Hello");
    }
}
Console output for GET /Home/Index
1. Authorization
2. Resource - before
3. Action - before
4. Action method
5. Action - after
6. Result - before
7. Result - after
8. Resource - after

The exception filter line is not printed, because nothing threw an exception. If the action threw one, you would see "Exception filter" after step 5.

Synchronous and asynchronous filters

Every filter type has a sync and an async interface:

// Synchronous: two separate methods, "before" and "after"
public class TimingFilter : IActionFilter
{
    public void OnActionExecuting(ActionExecutingContext context) { /* before */ }
    public void OnActionExecuted(ActionExecutedContext context)   { /* after  */ }
}

// Asynchronous: one method; code before "await next()" runs before, code after it runs after
public class AsyncTimingFilter : IAsyncActionFilter
{
    public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
    {
        // before the action
        ActionExecutedContext executed = await next();   // runs the action (and later filters)
        // after the action
    }
}
  • Use the async version when the filter needs to await something (database, cache, HTTP call).
  • Implement either the sync or the async interface, not both. If a class implements both, ASP.NET Core calls only the async one.

Short-circuiting: stopping the pipeline early

A "before" filter can stop the request by setting context.Result. Everything after it (including the action) is skipped, and that result is sent to the client.

public void OnActionExecuting(ActionExecutingContext context)
{
    if (!context.ModelState.IsValid)
    {
        context.Result = new BadRequestObjectResult(context.ModelState);   // the action will NOT run
    }
}

In an async filter, you short-circuit by setting context.Result and not calling await next().

Filter scopes: action, controller and global

A filter can be applied at three levels:

// 1. Action scope: only this action
public class OrdersController : ControllerBase
{
    [LogTime]
    public IActionResult Get() => Ok();
}

// 2. Controller scope: every action in the controller
[LogTime]
public class OrdersController : ControllerBase { ... }

// 3. Global scope: every action in the application (Program.cs)
builder.Services.AddControllers(options =>
{
    options.Filters.Add<LogTimeFilter>();
});

When the same filter type is applied at several levels, the "before" parts run from the outside in, and the "after" parts run from the inside out, like layers of an onion:

Execution order with filters at all three scopes
Global     - OnActionExecuting
Controller - OnActionExecuting
Action     - OnActionExecuting
   ACTION METHOD
Action     - OnActionExecuted
Controller - OnActionExecuted
Global     - OnActionExecuted

You can change this default order with the Order property: filters with a lower Order run their "before" code first. See lesson 3 for details.

Built-in filters you already use

AttributeFilter typeWhat it does
[Authorize]Authorization (enforced by UseAuthorization() middleware in modern apps)Allows only authenticated users, or users matching a role or policy
[ValidateAntiForgeryToken]AuthorizationProtects form posts against CSRF attacks
[RequireHttps]AuthorizationRedirects or rejects plain HTTP requests
[ResponseCache]ActionSets Cache-Control headers on the response
[Produces] / [Consumes]Result / ResourceRestrict response and request content types
[ApiController](adds an action filter)Automatically returns 400 when the model state is invalid
[ServiceFilter] / [TypeFilter]AnyApply a custom filter that needs dependency injection

Filters vs middleware

Both run code before and after a request, so which one should you use?

PointMiddlewareFilters
Runs forEvery request: MVC, minimal APIs, static files, Razor PagesOnly MVC/Web API actions (and Razor Pages with page filters)
Knows about MVC?No: only HttpContextYes: action name, arguments, ModelState, IActionResult
Can be applied to one actionNo (it's global)Yes, with an attribute
Good forHTTPS, CORS, compression, authentication, global exception handling, request loggingAction-level authorization, argument validation, MVC error responses, result shaping

Rule of thumb: if the logic needs MVC details (action arguments, model state, the action result) or should apply only to some actions, use a filter. If it applies to every request regardless of MVC, use middleware.

Summary

  • Filters run code before and after specific stages of an MVC/Web API request.
  • There are five types: Authorization, Resource, Action, Exception and Result, which run in that order.
  • Each type has a sync and an async interface; implement only one of them.
  • A "before" filter can short-circuit the pipeline by setting context.Result.
  • Filters can be applied to an action, a controller, or globally in Program.cs.
  • Use filters for MVC-specific cross-cutting logic and middleware for logic that applies to every request.

Next: build custom Authorization, Resource and Action filters.