Introduction
HTTP requests flow through multiple layers before reaching your API endpoint. Middleware intercepts every request and response at the application level, but operates too early to access your controller context and route data.
Filters intercept requests and responses at the MVC layer, between routing and your action methods. They access the controller, route values, and action metadata that middleware cannot reach. Move repetitive authorization checks, validation logic, and exception handling out of your controllers by centralizing them in filters.
What Filters Do in .NET
Filters in ASP.NET Core run after routing completes but before your action method executes. At this point they access the controller instance, action metadata, route values, and model-binding results that middleware never sees.
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.
HTTP Request
↓
Routing
↓
AUTHORIZATION FILTER (first)
↓
RESOURCE FILTER
↓
Action Method Execution
(+ MODEL BINDING)
↓
ACTION FILTER (OnActionExecuting)
↓
Controller Action Executes
↓
ACTION FILTER (OnActionExecuted)
↓
EXCEPTION FILTER
(if unhandled exception)
↓
RESULT FILTER
↓
Response Sent
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.
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 Problem Details 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.
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 runs early, before routing, without access to controller context. Filters run after routing with full access to controller and action metadata.
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.