ASP.NET Core Filters: Action Filters and Endpoint Filters
Middleware sees every request. Filters see the action about to run.
Middleware, from the last lesson, is deliberately unaware of your application. It sees a request, it sees a response — nothing more. It has no idea that "OrdersController.Create" is about to run, what arguments were bound to it, or whether those arguments passed validation. That's by design: middleware operates on the raw HTTP pipeline, agnostic of any framework built on top of it.
But sometimes you genuinely need that richer information. You want to log which specific action ran and with what arguments. You want to reject a request before the action runs if its model failed validation. You want to transform the result an action returns, right before it's serialized. None of that is visible from plain middleware — it only exists once you're inside the MVC framework's own action-invocation pipeline. That's exactly the gap filters fill.
In this lesson, you'll learn the five kinds of MVC filters, exactly where each one runs relative to your action, how they differ from middleware in scope and available context, and how the newer IEndpointFilter mechanism brings similar capability to Minimal APIs.
What Is It?
The Simple Explanation
A filter is a piece of code that runs at a specific, well-defined point around the execution of a Controller action — before it runs, after it runs, when it throws, or around how its result gets turned into an HTTP response. Unlike middleware, a filter has direct access to MVC-specific information: which action is about to execute, what arguments were bound to it, whether the model passed validation, and the controller instance itself.
The Technical Definition
Filters are components of the ASP.NET Core MVC framework that implement one of several filter interfaces (or their attribute-based equivalents), each corresponding to a specific stage of action execution. They can be applied to a single action method, to an entire controller class, or registered globally for every controller in the app. Under the hood, filters execute inside the endpoint that routing matched — from the middleware pipeline's point of view, "run the matched Controller action" is a single terminal step, and the filter pipeline is what happens inside that step.
Middleware
- Operates on the whole HTTP pipeline
- Agnostic of MVC — sees only
HttpContext - Runs for every request, regardless of endpoint type
- Order = registration order in
Program.cs
Filters
- Operate specifically inside the MVC action pipeline
- MVC-aware — sees the action, its bound arguments, model state, the controller
- Only run for requests handled by a Controller action
- Order = filter type, then scope (global → controller → action)
Why Does It Exist?
The Problem — Some Concerns Need MVC-Specific Context
Picture trying to write "log every action call along with its bound arguments and which controller handled it" as middleware. Middleware only has HttpContext — the raw request and response. It can see the URL path, but not "this maps to OrdersController.Create(CreateOrderRequest request), and here's what was actually bound into request after model binding ran." That information doesn't exist yet at the point middleware runs — it's produced deeper inside the MVC framework, as part of matching, binding, and preparing to invoke a specific action.
The Solution — Hooks Inside the Action-Invocation Pipeline
MVC exposes its own internal pipeline as a set of extension points — filters — positioned at each meaningful stage of preparing, running, and finishing an action. Because filters run inside MVC's own machinery, they get first-class access to everything MVC already knows: the action descriptor, the bound arguments, the ModelState, and the controller instance. You get surgical, MVC-aware interception without reimplementing any of MVC's own bookkeeping yourself.
Big Picture
Five filter types, each owning one stage of the journey from "a request matched this action" to "a response body was written":
- Run first. Decide whether the current user is allowed to invoke this action at all. Can short-circuit immediately with a 401/403 — before anything else runs, including model binding.
- Run next, wrapping almost everything after them — including model binding. Useful for short-circuiting before the (potentially expensive) work of binding even happens, e.g. serving a cached response directly.
- Not a filter itself — but this is where it happens, between resource filters and action filters. Full mechanics in lesson 258.
- Wrap the actual invocation of your action method. See the bound arguments before the action runs, and the produced result immediately after.
- Your controller code executes and returns an
IActionResult(or a value MVC wraps into one).
- Get a chance to observe or handle an unhandled exception raised anywhere earlier in this pipeline, before it would otherwise propagate up to middleware.
- Wrap the execution of the action's result — the step that actually writes the response (e.g. serializing an object to JSON). Can inspect or modify the outcome right before, and right after, it's written.
How It Works
public class TimingFilterAttribute : ActionFilterAttribute
{
public override void OnActionExecuting(ActionExecutingContext context) { /* before */ }
public override void OnActionExecuted(ActionExecutedContext context) { /* after */ }
}
ActionFilterAttributeboth implementsIActionFilterand derives fromAttribute, so the same class can be applied directly with[TimingFilter].
[TimingFilter]
public class OrdersController : ControllerBase { ... } // applies to every action in this controller
// or globally, in Program.cs:
builder.Services.AddControllers(options =>
{
options.Filters.Add<TimingFilterAttribute>();
});
- You never call a filter directly — MVC's action invoker runs each applicable filter, of each type, at exactly the pipeline stage that type owns, in the order shown in the Big Picture diagram.
Simple Example
An action filter that logs the action name and its bound arguments — information plain middleware simply doesn't have:
public class LogArgumentsFilter(ILogger<LogArgumentsFilter> logger) : IActionFilter
{
public void OnActionExecuting(ActionExecutingContext context)
{
var actionName = context.ActionDescriptor.DisplayName;
var arguments = string.Join(", ", context.ActionArguments.Select(a => $"{a.Key}={a.Value}"));
logger.LogInformation("Invoking {Action} with {Arguments}", actionName, arguments);
}
public void OnActionExecuted(ActionExecutedContext context)
{
logger.LogInformation("Finished {Action}, result type: {ResultType}",
context.ActionDescriptor.DisplayName, context.Result?.GetType().Name);
}
}
Why this couldn't be plain middleware: context.ActionDescriptor and context.ActionArguments are MVC concepts that don't exist yet at the point where middleware runs — they're only populated once routing has matched a Controller action and model binding has run. Because this class implements IActionFilter and is a real service (constructor-injected with ILogger), you register it via DI and apply it with [ServiceFilter(typeof(LogArgumentsFilter))] or add it globally.
Real-World Example
Before [ApiController] automated it (a story for lesson 257), the classic way to reject a request with invalid model state was exactly this kind of action filter — and it's still a useful pattern to understand, because it's the same shape teams use for other validation-adjacent, cross-cutting concerns today:
public class ValidateModelStateAttribute : ActionFilterAttribute
{
public override void OnActionExecuting(ActionExecutingContext context)
{
if (!context.ModelState.IsValid)
{
// Short-circuit: set the result here, and the action method never runs at all
context.Result = new BadRequestObjectResult(context.ModelState);
}
}
}
Setting context.Result inside OnActionExecuting is a filter's way of short-circuiting — much like skipping next() in middleware. Once it's set, the action invoker skips the action method entirely and moves straight to running result filters against the result you provided. This is a genuinely different, MVC-aware short-circuit than anything middleware could express, because it needs ModelState, which only exists after model binding has run for this specific action.
The Minimal API Equivalent — IEndpointFilter
Minimal APIs don't use Controllers, so classic MVC filters don't apply to them at all. Instead, ASP.NET Core provides IEndpointFilter — a newer, lighter mechanism covering many of the same cross-cutting use cases (logging, short-circuiting, transforming a result), designed specifically for the delegate-based Minimal API model. Lesson 256 covers IEndpointFilter in full detail; for now, the important thing is recognizing it as "the Minimal API answer to the question classic filters answer for Controllers" — not a competing concept, but the equivalent for a different handler style.
Under the Hood
- The middleware pipeline (lesson 254) calls into the matched endpoint as its final link in the chain. For a Controller action, that "endpoint" is actually the entire MVC action invoker — filters and all — running as a single unit from the outside.
- Within a given filter type, filters run in a defined order based on scope — global filters, then controller-level, then action-level — for the "before" phase, and the reverse for the "after" phase, mirroring the same nesting idea from middleware, just one level deeper and MVC-aware.
- Just like middleware not calling
next(), an authorization, resource, or action filter setting a result causes MVC to skip everything that would normally run after it (including the action method itself, for the earlier filter types) and jump straight to producing the response from that result.
Common Confusion
"Filters are just fancier middleware" — not quite
They share a similar shape (before/after, ability to short-circuit), but they operate at fundamentally different layers. Middleware runs for every request that passes through the app, before ASP.NET Core even knows which endpoint (if any) will handle it. Filters only run for requests handled by a Controller action, and only after routing, and often model binding, has already happened — so they can rely on information middleware never has access to.
Authorization filters vs. the UseAuthorization() middleware
These sound similar and address related concerns, but they're distinct mechanisms at different layers: UseAuthorization() middleware (lesson 254) is the standard, broadly-applicable way to enforce authorization policies across both Controllers and Minimal APIs. MVC authorization filters are an older, MVC-specific mechanism that predates unified endpoint-based authorization and still exists for advanced, MVC-only scenarios. In modern ASP.NET Core, the middleware-based approach (via [Authorize] and policies) is the standard recommendation for most apps.
Common Mistakes
Mistake 1 — Reaching for a filter when middleware is the better fit
Writing an action filter for something like global request logging or CORS handling — a concern that has nothing to do with actions specifically and should apply to every request, including ones that never reach a Controller (like static files or Minimal API endpoints).
If the concern doesn't genuinely need action-specific context, middleware is simpler, runs for all endpoint types, and doesn't tie the logic to MVC.
Mistake 2 — Forgetting filters only apply to Controllers, not Minimal API endpoints
Applying an ActionFilterAttribute-based filter and expecting it to run for a Minimal API route mapped with app.MapGet(...). It won't — classic MVC filters are part of the Controller/action-invocation machinery, which Minimal APIs don't use.
For Minimal APIs, use IEndpointFilter instead (lesson 256).
Mistake 3 — Using the wrong filter type for the job
Implementing expensive short-circuit logic (like a cache lookup) as an action filter, which runs after model binding has already done its work — wasting that effort on requests you're about to serve from cache anyway.
A resource filter runs earlier, wrapping model binding itself, so a cache short-circuit implemented there avoids the unnecessary binding work entirely.
When Should I Use It?
An honest way to choose between the three tools this Part covers for cross-cutting concerns:
Middleware
- Applies to all or most requests, regardless of endpoint style
- Doesn't need action-specific, bound, or validated data
- Logging, exception handling, CORS, static files, auth
MVC Filters
- Your app uses Controllers and needs action-aware behavior
- You need access to bound arguments, model state, or the controller instance
- Custom validation shortcuts, response shaping, controller-scoped concerns
Endpoint Filters (IEndpointFilter)
- Your app uses Minimal APIs and needs similar per-endpoint behavior
- You want cross-cutting logic scoped to specific routes or a
MapGroup - Covered in full in lesson 256
Mental Model
Filters = sees the specific action, its arguments, and its validation state — because they run inside MVC
Authorization → Resource → (model binding) → Action → (your code runs) → Exception (if needed) → Result
Remember:
· Filters live inside the one "endpoint" step middleware sees as a single opaque unit.
· Setting a filter's result short-circuits the rest of the filter pipeline, just like skipping
next() does for middleware.· No Controllers, no classic filters — Minimal APIs use
IEndpointFilter instead.
Key Takeaway
- Filters — Authorization, Resource, Action, Exception, and Result — hook into specific, well-defined points around Controller action execution, in that order.
- Filters get access to MVC-specific context (the matched action, bound arguments, model validation state, the controller instance) that plain middleware never has.
- Middleware operates on the whole HTTP pipeline, agnostic of MVC; filters operate strictly within the MVC action-invocation pipeline, for Controller-based apps only.
IEndpointFilteris the modern, Minimal-API-compatible equivalent for apps that don't use Controllers — full treatment in the next lesson.- Choose based on what the concern needs to know: request-only → middleware; action-aware and using Controllers → filter; action-aware and using Minimal APIs → endpoint filter.
Check Your Understanding
You've seen the five filter types and how they relate to middleware. Let's check whether the distinction has stuck.
1. What is the core distinction between middleware and MVC filters?
Show answer
Correct: B
Why B is correct: Middleware sees only the raw HttpContext and has no concept of "actions." Filters run inside MVC's own action-invocation pipeline and have access to the matched action, its bound arguments, model validation state, and the controller instance.
Why A is incorrect: HTTP verb has nothing to do with whether middleware or filters apply.
Why C is incorrect: Filters still run after routing has matched an endpoint — they don't skip it.
Why D is incorrect: They're related in shape (both can run before/after and short-circuit) but operate at genuinely different layers with different available context.
Reinforcement: Scope and available context are the real distinction — not speed or HTTP verb.
2. An action filter's OnActionExecuting method sets context.Result to a BadRequestObjectResult. What happens next?
Show answer
Correct: B
Why B is correct: Setting context.Result in OnActionExecuting is the standard way an action filter short-circuits — the action method itself never runs, and the pipeline moves directly to result filters and producing the response.
Why A is incorrect: Once the result is set this way, the action method is bypassed — it doesn't run at all, so nothing overwrites your result.
Why C is incorrect: This is a completely standard, supported, and common pattern (it's exactly how pre-[ApiController] model-state validation filters worked).
Why D is incorrect: A response is still produced — from the result you explicitly set, via the result-filter stage.
Reinforcement: Setting a result inside a filter is the MVC-aware equivalent of skipping next() in middleware.
3. Why can't a resource filter's caching short-circuit be implemented at the action-filter stage instead, without losing any benefit?
Show answer
Correct: B
Why B is correct: Resource filters wrap model binding itself, so a cache-hit short-circuit there skips binding entirely. An action filter runs after binding has already completed, so you'd pay the binding cost on every request even when serving from cache.
Why A is incorrect: Action filters can absolutely set a result too — the difference is about pipeline position and what work has already happened by that point, not capability.
Why C is incorrect: The stages genuinely differ in what has run before them, which is precisely why choosing the right filter type matters.
Why D is incorrect: Action filters run for any HTTP verb reaching a matched action — this isn't verb-specific.
Reinforcement: Picking the earliest filter type that can do the job avoids wasted work later in the pipeline.
4. You've applied a classic ActionFilterAttribute-based filter to log every action call, but it never fires for one particular endpoint. That endpoint is registered with app.MapGet("/health", () => "ok"). Why?
Show answer
Correct: B
Why B is correct: Classic MVC filters are part of the Controller/action-invocation machinery. A Minimal API endpoint mapped with MapGet never goes through that machinery at all, so ActionFilterAttribute-based filters simply don't apply to it — IEndpointFilter is the equivalent for that style (lesson 256).
Why A is incorrect: There's nothing special about the route name /health — the issue is the endpoint style, not the path.
Why C is incorrect: HTTP verb is irrelevant here — the issue is Minimal API vs. Controller, not GET vs. POST.
Why D is incorrect: [ApiController] is a Controller-class attribute; it has no bearing on Minimal API endpoints, which don't use Controller classes at all.
Reinforcement: Filters are Controller-pipeline-specific — Minimal APIs need IEndpointFilter for equivalent behavior.
5. You need a cross-cutting concern applied to every single request in your app — including static file requests and Minimal API endpoints, not just Controller actions. Which is the correct tool?
Show answer
Correct: B
Why B is correct: Middleware is the only one of these that applies uniformly to every request regardless of how — or whether — it's eventually routed to a Controller action or a Minimal API endpoint, because it runs before that distinction is even resolved.
Why A is incorrect: Even a global filter only applies to requests that reach a Controller action — static files and Minimal API endpoints never enter the MVC filter pipeline at all.
Why C, D are incorrect: Both are scoped to specific endpoint styles (Minimal APIs, or Controllers respectively) — neither uniformly covers every request across both styles and static files the way middleware does.
Reinforcement: When a concern must be universal across every request type, middleware is the tool built for exactly that scope.
You now know exactly where filters fit relative to middleware, and how to pick the right interception point for a given concern.