ASP.NET Core Filters
Authorization, resource, action, exception, and result filters β the pipeline within the pipeline.
ASP.NET Core filters form a secondary pipeline inside the endpoint middleware. They execute in a strict order β Authorization, Resource, Action, Result β with Exception filters acting as a catch-all safety net. Each filter type has specific power and specific limitations: knowing which filter to use, and understanding that filters don't catch middleware exceptions, separates correct implementations from brittle ones.
Filters are a sub-pipeline that runs inside the endpoint middleware. After the endpoint is matched and model binding completes, filters execute in a specific order: Authorization β Resource β Action β (action executes) β Result. Exception filters wrap the entire sequence.
IAuthorizationFilter runs before model binding. This is intentional β you shouldn't bind the request body or allocate resources for unauthorized requests. If an authorization filter sets context.Result, the entire remaining pipeline is short-circuited.
IResourceFilter runs after authorization but before model binding. It wraps action filters, the action, and result filters. Used for caching β the resource filter can return a cached result before model binding even runs, saving significant processing.
IActionFilter (or IAsyncActionFilter) has OnActionExecuting (before action) and OnActionExecuted (after action). The async version receives an ActionExecutionDelegate β calling await next() executes the action and returns the result. Anything after next() runs on the way back.
IResultFilter runs OnResultExecuting before the IActionResult.ExecuteResultAsync() and OnResultExecuted after. Used to add response headers (ETag, Cache-Control) or transform results. Runs even if the action short-circuited by setting context.Result in an action filter.
IExceptionFilter catches exceptions from action filters and the action itself. Set ExceptionHandled = true to stop propagation. Critical: exception filters do NOT catch exceptions from resource filters or middleware β only from action filters and the action.
Key Concepts
Runs before model binding. Short-circuit by setting context.Result. Use for custom auth logic not covered by policy-based authorization.
Wraps model binding, action filters, and result filters. OnResourceExecuting runs before model binding. OnResourceExecuted runs after result filters. Used for output caching.
Single method: OnActionExecutionAsync(context, next). await next() executes the action. Check executed.Exception after next() to handle exceptions. Prefer this over IActionFilter for async work.
Wraps result execution. Runs even when the action short-circuits via context.Result. Perfect for adding response headers to all responses from a controller.
Catches exceptions from action filters and actions. Does NOT catch exceptions from middleware or resource filters. Set context.ExceptionHandled = true to prevent re-throw.
ServiceFilter(typeof(T)) resolves T from DI β T must be registered. TypeFilter(typeof(T)) creates T using DI for its own constructor but doesn't require T to be registered. Use ServiceFilter for singleton/scoped filters.
1// Authorization filter β runs before model binding, can short-circuit2public class RequireApiKeyFilter : IAuthorizationFilter3{4 public void OnAuthorization(AuthorizationFilterContext context)5 {6 if (!context.HttpContext.Request.Headers.TryGetValue("X-Api-Key", out var key)7 || key != "secret")8 {9 context.Result = new UnauthorizedResult(); // Short-circuits entire pipeline10 }11 }12}1314// Action filter β runs around action execution (before AND after)15public class PerformanceLoggingFilter : IAsyncActionFilter16{17 private readonly ILogger<PerformanceLoggingFilter> _logger;1819 public PerformanceLoggingFilter(ILogger<PerformanceLoggingFilter> logger)20 => _logger = logger;2122 public async Task OnActionExecutionAsync(23 ActionExecutingContext context,24 ActionExecutionDelegate next)25 {26 var sw = Stopwatch.StartNew();27 var executed = await next(); // Execute the action + remaining action filters28 sw.Stop();2930 if (executed.Exception != null && !executed.ExceptionHandled)31 {32 _logger.LogError(executed.Exception, "Action {Action} threw after {Elapsed}ms",33 context.ActionDescriptor.DisplayName, sw.ElapsedMilliseconds);34 }35 else36 {37 _logger.LogInformation("Action {Action} completed in {Elapsed}ms",38 context.ActionDescriptor.DisplayName, sw.ElapsedMilliseconds);39 }40 }41}4243// Exception filter β catches exceptions from action + action filters44public class GlobalExceptionFilter : IExceptionFilter45{46 public void OnException(ExceptionContext context)47 {48 if (context.Exception is ValidationException ex)49 {50 context.Result = new BadRequestObjectResult(new { errors = ex.Errors });51 context.ExceptionHandled = true; // Prevents further exception filters from running52 }53 // If ExceptionHandled stays false, the exception propagates to middleware54 }55}5657// Result filter β runs around result execution58public class ETagResultFilter : IResultFilter59{60 public void OnResultExecuting(ResultExecutingContext context)61 {62 if (context.Result is ObjectResult objectResult)63 {64 var etag = GenerateETag(objectResult.Value);65 context.HttpContext.Response.Headers.ETag = etag;66 }67 }6869 public void OnResultExecuted(ResultExecutedContext context) { }70}7172// Registration β filter scope matters for order73builder.Services.AddControllers(options =>74{75 options.Filters.Add<GlobalExceptionFilter>(); // Global scope76 options.Filters.Add<ETagResultFilter>();77 options.Filters.Add(new RequireApiKeyFilter(), order: 1);78});7980// Controller scope81[ServiceFilter(typeof(PerformanceLoggingFilter))]82public class OrdersController : ControllerBase { }8384// Action scope (most specific)85[TypeFilter(typeof(RequireApiKeyFilter))]86public IActionResult SensitiveAction() => Ok();
Filters let you implement cross-cutting concerns (auth, logging, caching, error handling) in a way that's composable and testable. But the non-obvious behaviors β result filters running after short-circuits, exception filters missing middleware errors, and undefined order within a scope β are the source of production bugs that take hours to isolate.
Common Pitfalls
1Exception Filter Not Catching Middleware Exceptions
You add a global IExceptionFilter to catch all exceptions and return formatted JSON errors. It works for most cases, but some 500 errors still return the default HTML error page β specifically when UseRateLimiting() throws, and when your custom authentication middleware fails.
Exception filters only catch exceptions thrown by action filters and the action itself. Middleware exceptions (from UseRateLimiting, UseAuthentication, etc.) happen OUTSIDE the filter pipeline. They propagate to the middleware pipeline where no exception filter can intercept them.
Use UseExceptionHandler or a custom exception-handling middleware for exceptions thrown by other middleware. Your exception filter handles action-level exceptions; your exception middleware handles everything else. A common pattern is to duplicate the error formatting logic in both places, or extract it to a shared IErrorResponseFactory service.
Takeaway: Exception filters and exception middleware are not interchangeable. Filters only cover the action pipeline. For a consistent error format across all error types, you need BOTH: exception filter for action errors, and UseExceptionHandler for middleware errors.
2Result Filters Firing When You Expected Short-Circuit
You have a result filter that adds Cache-Control headers to all responses. An action filter short-circuits a request by setting context.Result = new UnauthorizedResult(). You check the network tab and see Cache-Control headers on the 401 response β caching unauthorized responses crashes the client.
Result filters run even when an action filter short-circuits by setting context.Result. The filter pipeline still executes OnResultExecuting for all result filters before writing the response. Your 'add cache headers' filter had no check for the response status code.
In your result filter's OnResultExecuting, check the result type or status code before adding headers: if (context.Result is ObjectResult { StatusCode: >= 400 }) return; Or check the response status: if (context.HttpContext.Response.StatusCode >= 400) return;. Never blindly add cache headers without checking whether the response should be cached.
Takeaway: Short-circuiting in action filters is NOT short-circuiting for result filters. Result filters ALWAYS run unless the result filter itself short-circuits (by setting context.Cancel = true). Guard every result filter against error responses.
3Filter Execution Order Within Same Scope Is Undefined
You have three action filters at controller scope: audit logging, performance tracking, and rate limit checking. In development they run in registration order. After a NuGet update, they run in different order. The rate limit check now runs AFTER the audit log, meaning rate-limited requests are being logged as legitimate requests.
When multiple filters have the same scope (all three are controller-scoped), ASP.NET Core makes no guarantee about execution order. The actual order depends on reflection and attribute ordering, which can change between framework versions or compilation settings.
Implement IOrderedFilter or set the Order property on your FilterAttribute. Lower Order values run first in OnXxxExecuting and last in OnXxxExecuted. Explicitly assign: RateLimitFilter.Order = 1, AuditFilter.Order = 2, PerformanceFilter.Order = 3. Add a test that verifies the filter execution order.
Takeaway: If filter execution order matters β and it usually does β you MUST set the Order property. Relying on implicit ordering is a ticking time bomb that manifests after framework updates or when the codebase is reorganized. Document the intended order in comments alongside the filter registration.