HTTP requests flow through multiple layers before reaching your API endpoint. Middleware intercepts requests and responses at the application level. Its access to routing information depends on where it runs: middleware placed after route matching can read the selected endpoint and route values, but it does not receive MVC-specific controller, action-argument, or model-binding contexts.
Filters intercept requests and responses inside the MVC action invocation pipeline. They expose strongly typed contexts for the selected controller action, bound action arguments, model state, results, and filter-specific short-circuiting. Move MVC-specific cross-cutting logic out of controllers by centralizing it in filters.
What Filters Do in .NET
Filters in ASP.NET Core run after ASP.NET Core selects the action to execute. Depending on the filter stage, they can access the controller instance, action metadata, route values, model state, bound arguments, and action result. Middleware can access HttpContext, endpoint metadata, and route values when ordered appropriately, but not these MVC filter contexts.
Why Filters Matter
Reusability. Write once, apply everywhere.
Modularity. Authorization logic lives in one place, not scattered across actions.
Maintainability. Change the filter once. The change applies to all registrations.
Filter Execution Pipeline
Filters run in a predictable order. Knowing this determines how you use them.
Filter Execution Order
Filters execute in a nested sequence. Global filters wrap controller filters, which wrap action filters. Executing filters run in order; after-execution handlers run in reverse.
| Scope | On Execution | On Completion |
|---|---|---|
| Global | Authorization and validation | Cleanup and logging |
| Controller | Controller-level checks | Controller-level cleanup |
| Action | Action-specific logic | Action-level cleanup |
Example: Nested Filter Execution
Register filters at all three levels and a single request flows: Global → Controller → Action → execute → Action (reverse) → Controller (reverse) → Global (reverse).
Six Built-In Filter Types
ASP.NET Core provides six filter types, each at a different pipeline stage.
| Filter Type | When it runs | Use it for |
|---|---|---|
| Authorization | Before action (first) | Check user permissions, validate API keys (IAsyncAuthorizationFilter) |
| Resource | After auth, before binding | Validate resources, short-circuit (IAsyncResourceFilter) |
| Action | After binding, before/after action | Logging, transformation, validation (IAsyncActionFilter) |
| Exception | When unhandled exception occurs | Centralized error handling (IAsyncExceptionFilter) |
| Result | After action, before serialization | Modify headers, transform response (IAsyncResultFilter) |
| Endpoint | Minimal API entry point | Validation, logging (IEndpointFilter) |
Authorization Filter
IAsyncAuthorizationFilter runs first, immediately after routing. If authorization fails, short-circuit the request and prevent all further processing.
Use it to check user permissions, validate API keys, or enforce security policies before the action runs.
public sealed class ApiKeyAuthorizationFilter(
DemoAuditLog auditLog,
IConfiguration configuration)
: IAsyncAuthorizationFilter
{
private const string ApiKeyHeader = "X-Api-Key";
public async Task OnAuthorizationAsync(AuthorizationFilterContext context)
{
var suppliedKey = context.HttpContext.Request.Headers[ApiKeyHeader].ToString();
var expectedKey = configuration["DemoApiKey"];
if (string.IsNullOrEmpty(expectedKey) || !KeysMatch(suppliedKey, expectedKey))
{
await auditLog.WriteAsync(
"Authorization rejected the request.",
context.HttpContext.RequestAborted);
context.HttpContext.Response.Headers.WWWAuthenticate = "ApiKey";
context.Result = new UnauthorizedObjectResult(new ProblemDetails
{
Status = StatusCodes.Status401Unauthorized,
Title = "Unauthorized",
Detail = $"Supply a valid API key in the {ApiKeyHeader} header.",
Instance = context.HttpContext.Request.Path
});
return;
}
await auditLog.WriteAsync(
"Authorization accepted the request.",
context.HttpContext.RequestAborted);
}
private static bool KeysMatch(string supplied, string expected)
{
var suppliedBytes = Encoding.UTF8.GetBytes(supplied);
var expectedBytes = Encoding.UTF8.GetBytes(expected);
return CryptographicOperations.FixedTimeEquals(suppliedBytes, expectedBytes);
}
}
The filter compares the supplied key against the configuration value using CryptographicOperations.FixedTimeEquals, which prevents timing attacks. On mismatch, return a ProblemDetails response and log the failure.
The filter reads configuration["DemoApiKey"] straight off IConfiguration. See Configuration in .NET for how that value gets there from appsettings.json, environment variables, or the command line.
Resource Filter
IAsyncResourceFilter runs second, after authorization but before model binding. It’s the first chance to access the HTTP context after auth succeeds.
Use it to validate required resources exist, measure request duration, or perform caching.
public sealed class TimingResourceFilter(DemoAuditLog auditLog) : IAsyncResourceFilter
{
public async Task OnResourceExecutionAsync(
ResourceExecutingContext context,
ResourceExecutionDelegate next)
{
var stopwatch = Stopwatch.StartNew();
context.HttpContext.Response.OnStarting(() =>
{
context.HttpContext.Response.Headers["X-Resource-Time-Ms"] =
stopwatch.ElapsedMilliseconds.ToString();
return Task.CompletedTask;
});
await auditLog.WriteAsync(
"Resource filter: before the remaining MVC pipeline.",
context.HttpContext.RequestAborted);
await next();
stopwatch.Stop();
await auditLog.WriteAsync(
$"Resource filter: after the pipeline ({stopwatch.ElapsedMilliseconds} ms).",
context.HttpContext.RequestAborted);
}
}
The OnStarting callback adds the timing header before the response writes to the network.
Action Filter
IAsyncActionFilter runs third, after model binding. It accesses the bound model, route data, controller instance, and action metadata.
Use it for validation that depends on the bound model, transformation of incoming or outgoing data, or action-specific logging.
public sealed class ValidateItemActionFilter(DemoAuditLog auditLog) : IAsyncActionFilter
{
public async Task OnActionExecutionAsync(
ActionExecutingContext context,
ActionExecutionDelegate next)
{
await auditLog.WriteAsync(
"Action filter: before the controller action.",
context.HttpContext.RequestAborted);
var request = context.ActionArguments.Values.OfType<CreateItemRequest>().FirstOrDefault();
if (request is not null && string.IsNullOrWhiteSpace(request.Name))
{
context.Result = new BadRequestObjectResult(new ProblemDetails
{
Status = StatusCodes.Status400BadRequest,
Title = "Validation failed",
Detail = "Name is required (returned by the action filter).",
Instance = context.HttpContext.Request.Path
});
return;
}
var executedContext = await next();
await auditLog.WriteAsync(
$"Action filter: after the action; canceled={executedContext.Canceled}.",
context.HttpContext.RequestAborted);
}
}
Return a BadRequestObjectResult without calling next() to short-circuit and prevent the action from executing.
Exception Filter
IAsyncExceptionFilter runs when an unhandled exception occurs. It centralizes error handling and transforms exceptions into HTTP responses.
For application-wide error handling, use middleware with IExceptionHandler or the ProblemDetails pattern instead. Middleware operates at the application level and catches errors filters cannot.
public sealed class ApiExceptionFilter(DemoAuditLog auditLog) : IAsyncExceptionFilter
{
public async Task OnExceptionAsync(ExceptionContext context)
{
await auditLog.WriteAsync(
$"Exception filter handled {context.Exception.GetType().Name}.",
context.HttpContext.RequestAborted);
context.Result = new ObjectResult(new ProblemDetails
{
Status = StatusCodes.Status500InternalServerError,
Title = "The demo action failed.",
Detail = context.Exception.Message,
Instance = context.HttpContext.Request.Path
})
{
StatusCode = StatusCodes.Status500InternalServerError
};
context.ExceptionHandled = true;
}
}
Set ExceptionHandled = true to mark the exception as handled and prevent propagation.
Result Filter
IAsyncResultFilter runs after the action executes and you create the result, before serialization and response. It inspects or modifies response headers and the action result itself.
Use IAsyncAlwaysRunResultFilter if your filter must run even when a prior filter short-circuits.
This example adds a custom response header before the result is serialized:
public sealed class ResponseHeaderResultFilter(DemoAuditLog auditLog) : IAsyncResultFilter
{
public async Task OnResultExecutionAsync(
ResultExecutingContext context,
ResultExecutionDelegate next)
{
context.HttpContext.Response.Headers["X-Result-Filter"] = "executed";
await auditLog.WriteAsync(
"Result filter: before serializing the action result.",
context.HttpContext.RequestAborted);
var executedContext = await next();
await auditLog.WriteAsync(
$"Result filter: after the result; canceled={executedContext.Canceled}.",
context.HttpContext.RequestAborted);
}
}
Add response headers, modify content type, or transform the serialized response.
Endpoint Filter
IEndpointFilter is for Minimal APIs. It runs before and after the endpoint and accesses its arguments and result.
Use it with Minimal APIs to validate arguments, log requests, or modify the response. Controllers use action filters instead. For argument validation specifically, .NET 10 also added built-in Minimal API validation as an alternative to writing it by hand in a filter.
This example validates that a required name parameter is provided:
public sealed class RequiredNameEndpointFilter(DemoAuditLog auditLog) : IEndpointFilter
{
public async ValueTask<object?> InvokeAsync(
EndpointFilterInvocationContext context,
EndpointFilterDelegate next)
{
var name = context.GetArgument<string?>(0);
if (string.IsNullOrWhiteSpace(name))
{
await auditLog.WriteAsync(
"Endpoint filter rejected an empty name.",
context.HttpContext.RequestAborted);
return TypedResults.Problem(new ProblemDetails
{
Status = StatusCodes.Status400BadRequest,
Title = "Validation failed",
Detail = "Query-string parameter 'name' is required.",
Instance = context.HttpContext.Request.Path
});
}
await auditLog.WriteAsync(
"Endpoint filter: before the Minimal API handler.",
context.HttpContext.RequestAborted);
var result = await next(context);
await auditLog.WriteAsync(
"Endpoint filter: after the Minimal API handler.",
context.HttpContext.RequestAborted);
return result;
}
}
Registering Filters
Global Filter Registration
Register globally in Program.cs to apply to every action:
builder.Services.AddControllers(options =>
{
options.Filters.Add<ApiKeyAuthorizationFilter>();
options.Filters.Add<ApiExceptionFilter>();
options.Filters.Add<ResponseHeaderResultFilter>();
});
Every request passes through these filters.
Alternatively, use dependency injection:
builder.Services.AddControllers(options =>
{
options.Filters.AddServiceFilter<ApiKeyAuthorizationFilter>();
options.Filters.AddServiceFilter<ApiExceptionFilter>();
});
builder.Services.AddScoped<ApiKeyAuthorizationFilter>();
builder.Services.AddScoped<ApiExceptionFilter>();
Controller-Level Filter Registration
Apply to all actions in a controller using [ServiceFilter] or [TypeFilter] on the controller class:
[ServiceFilter(typeof(TimingResourceFilter))]
[ServiceFilter(typeof(ValidateItemActionFilter))]
public class ItemsController : ControllerBase
{
[HttpPost]
public async Task<IActionResult> CreateItem([FromBody] CreateItemRequest request) { }
[HttpGet("{id}")]
public async Task<IActionResult> GetItem(int id) { }
}
Both actions pass through both filters. Use [TypeFilter] to pass constructor parameters not in the dependency container:
[TypeFilter(typeof(TimingResourceFilter))]
public class ItemsController : ControllerBase { }
Action-Level Filter Registration
Apply to a single action using [ServiceFilter] or [TypeFilter] on the method:
public class ItemsController : ControllerBase
{
[HttpPost]
[ServiceFilter(typeof(ValidateItemActionFilter))]
public async Task<IActionResult> CreateItem([FromBody] CreateItemRequest request) { }
[HttpGet("{id}")]
public async Task<IActionResult> GetItem(int id) { }
}
Filters vs. Middleware
Middleware and filters both intercept requests, but at different layers. Middleware ordering is configurable: code that runs after route matching can inspect the selected endpoint and route values, and middleware can wrap a broader part of the application than MVC alone. See Microsoft’s routing pipeline guidance.
Filters run inside MVC after action selection and provide stage-specific contexts for authorization, resource handling, bound action arguments, exceptions, and results. Use middleware for application-wide HTTP concerns; use filters when the behavior depends on MVC action semantics.
Conclusion
Filters intercept requests and responses at the MVC layer. Six built-in types run in predictable order. Register globally, per controller, or per action to control scope.
Find one piece of repetitive logic in your actions: an auth check, a validation rule, a logging statement. Move it into a filter. Combine filters with middleware and dependency injection for clean APIs.