AI WisdomArchitecture & guides β†—
HT
How Things Work

ASP.NET Core Filters

Authorization, resource, action, exception, and result filters β€” the pipeline within the pipeline.

How It Works

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.

1
Filter Pipeline Within the Request Pipeline

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.

2
Authorization Filters Run First

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.

3
Resource Filters Wrap Everything Else

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.

4
Action Filters Wrap the Action

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.

5
Result Filters Wrap Result Execution

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.

6
Exception Filters as Last Resort

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

πŸ”IAuthorizationFilter

Runs before model binding. Short-circuit by setting context.Result. Use for custom auth logic not covered by policy-based authorization.

πŸ—„οΈIResourceFilter

Wraps model binding, action filters, and result filters. OnResourceExecuting runs before model binding. OnResourceExecuted runs after result filters. Used for output caching.

βš™οΈIAsyncActionFilter

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.

πŸ“€IResultFilter

Wraps result execution. Runs even when the action short-circuits via context.Result. Perfect for adding response headers to all responses from a controller.

🚨IExceptionFilter

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 vs TypeFilter

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.

Filter Types, DI Registration & Scope
tsx
1// Authorization filter β€” runs before model binding, can short-circuit
2public class RequireApiKeyFilter : IAuthorizationFilter
3{
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 pipeline
10 }
11 }
12}
13
14// Action filter β€” runs around action execution (before AND after)
15public class PerformanceLoggingFilter : IAsyncActionFilter
16{
17 private readonly ILogger<PerformanceLoggingFilter> _logger;
18
19 public PerformanceLoggingFilter(ILogger<PerformanceLoggingFilter> logger)
20 => _logger = logger;
21
22 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 filters
28 sw.Stop();
29
30 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 else
36 {
37 _logger.LogInformation("Action {Action} completed in {Elapsed}ms",
38 context.ActionDescriptor.DisplayName, sw.ElapsedMilliseconds);
39 }
40 }
41}
42
43// Exception filter β€” catches exceptions from action + action filters
44public class GlobalExceptionFilter : IExceptionFilter
45{
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 running
52 }
53 // If ExceptionHandled stays false, the exception propagates to middleware
54 }
55}
56
57// Result filter β€” runs around result execution
58public class ETagResultFilter : IResultFilter
59{
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 }
68
69 public void OnResultExecuted(ResultExecutedContext context) { }
70}
71
72// Registration β€” filter scope matters for order
73builder.Services.AddControllers(options =>
74{
75 options.Filters.Add<GlobalExceptionFilter>(); // Global scope
76 options.Filters.Add<ETagResultFilter>();
77 options.Filters.Add(new RequireApiKeyFilter(), order: 1);
78});
79
80// Controller scope
81[ServiceFilter(typeof(PerformanceLoggingFilter))]
82public class OrdersController : ControllerBase { }
83
84// Action scope (most specific)
85[TypeFilter(typeof(RequireApiKeyFilter))]
86public IActionResult SensitiveAction() => Ok();
πŸ’‘
Why This Matters

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

⚠Exception filters do NOT catch exceptions from middleware (UseAuthentication, UseRateLimiting, custom middleware). They only catch exceptions from action filters and the action method itself.
⚠Result filters run even when the action short-circuited by setting context.Result in an action filter. If you have a caching result filter, it will add cache headers to 401 and 403 responses unless you explicitly guard against it.
⚠Filter execution order within the same scope (global, controller, or action) is NOT guaranteed. If order matters, implement IOrderedFilter or set the Order property explicitly. This will bite you after a NuGet update.
⚠IAsyncActionFilter and IActionFilter should NOT both be implemented on the same class. If you implement both, only the async version runs β€” the sync version is completely ignored.
⚠Resource filters run around model binding. If an IResourceFilter.OnResourceExecuting sets context.Result to short-circuit, the action filter pipeline AND result filters still run β€” but the action does not. This is a common source of confusion about what 'short-circuit' means at the resource filter level.
Real-World Use Cases

1Exception Filter Not Catching Middleware Exceptions

Scenario

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.

Problem

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.

Solution

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

Scenario

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.

Problem

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.

Solution

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

Scenario

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.

Problem

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.

Solution

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.