The MVC filter pipeline has a precise execution order. Understanding this order is essential for building correct, composable filters: if an authorization filter short-circuits, no subsequent filters run; if an action filter sets a result, the result filter still runs but the action does not. Getting filter order right ensures that cross-cutting concerns interact predictably — audit logging captures the full outcome, security headers appear on every response, and exception handling operates at the right layer.
Complete Filter Pipeline Order
// ── Filter execution order for a successful request ───────────────────────
//
// 1. Authorization Filters OnAuthorization()
// ↓ (if authorized)
// 2. Resource Filters OnResourceExecuting()
// ↓ (before model binding)
// 3. Model Binding (populates action parameters)
// ↓
// 4. Action Filters OnActionExecuting()
// ↓
// 5. Action Method PostsController.Create()
// ↓
// 6. Action Filters OnActionExecuted()
// ↓
// 7. Result Filters OnResultExecuting()
// ↓
// 8. Result Execution ViewResult.ExecuteResultAsync() → HTML rendered
// ↓
// 9. Result Filters OnResultExecuted()
// ↓
// 10. Resource Filters OnResourceExecuted()
//
// If an exception occurs in steps 4-6:
// → Exception Filters OnException()
// ↓ (if handled)
// → Back to Result Filters (steps 7-10) with the exception filter's result
// ── Filter Order property — lower numbers run first ───────────────────────
public class LogFilter : IActionFilter, IOrderedFilter
{
public int Order => -1000; // runs BEFORE other unordered filters
public void OnActionExecuting(ActionExecutingContext context) { }
public void OnActionExecuted(ActionExecutedContext context) { }
}
// ── Global filter with explicit order ─────────────────────────────────────
builder.Services.AddControllersWithViews(opts =>
{
opts.Filters.Add(new LogActionAttribute(), order: -1000); // runs first
opts.Filters.Add(new AuditFilter(), order: 0);
opts.Filters.Add(new CacheFilter(), order: 100); // runs last
});
IOrderedFilter and set the Order property — lower numbers run first (on the way in) and last (on the way out) for the same filter type.LogActionAttribute plus [LogAction] on a specific action means that action’s logs appear twice. Either register globally OR at specific scopes, not both. If you need to override a global filter’s behaviour for specific actions, use IFilterFactory or check the action descriptor inside the filter to handle specific cases differently.Audit Logging Filter — Comprehensive Example
// ── Captures user, action, parameters, result, timing, and exceptions ─────
public class AuditLoggingFilter(
IAuditService auditService,
ILogger<AuditLoggingFilter> logger) : IAsyncActionFilter, IOrderedFilter
{
public int Order => int.MinValue + 10; // run very early (before other action filters)
public async Task OnActionExecutionAsync(
ActionExecutingContext context,
ActionExecutionDelegate next)
{
var entry = new AuditEntry
{
UserId = context.HttpContext.User.FindFirstValue(ClaimTypes.NameIdentifier),
UserName = context.HttpContext.User.Identity?.Name,
Controller = context.RouteData.Values["controller"]?.ToString(),
Action = context.RouteData.Values["action"]?.ToString(),
IpAddress = context.HttpContext.Connection.RemoteIpAddress?.ToString(),
RequestPath = context.HttpContext.Request.Path,
Timestamp = DateTime.UtcNow,
// Safely capture action arguments (mask sensitive ones)
Parameters = context.ActionArguments
.Where(a => !IsSensitive(a.Key))
.ToDictionary(a => a.Key, a => a.Value?.ToString()),
};
var stopwatch = Stopwatch.StartNew();
var executedContext = await next();
stopwatch.Stop();
entry.ElapsedMs = stopwatch.ElapsedMilliseconds;
entry.Succeeded = executedContext.Exception is null;
entry.StatusCode = GetStatusCode(executedContext);
await auditService.LogAsync(entry);
if (executedContext.Exception is not null)
logger.LogError(executedContext.Exception,
"Action failed: {Controller}.{Action}", entry.Controller, entry.Action);
}
private static bool IsSensitive(string key) =>
key.Contains("password", StringComparison.OrdinalIgnoreCase) ||
key.Contains("token", StringComparison.OrdinalIgnoreCase) ||
key.Contains("secret", StringComparison.OrdinalIgnoreCase);
private static int GetStatusCode(ActionExecutedContext ctx) =>
ctx.Result is ObjectResult r ? r.StatusCode ?? 200
: ctx.HttpContext.Response.StatusCode;
}
Filter vs Middleware Decision Guide
| Concern | Filter | Middleware |
|---|---|---|
| Security headers | Result filter (actions only) | ✅ Middleware (all responses) |
| Request timing | ✅ Action filter (with action context) | Middleware (basic HTTP level) |
| API key auth | ✅ Authorization filter (per controller) | Middleware (all routes) |
| Exception mapping | ✅ Exception filter (domain exceptions) | ✅ Middleware (all exceptions) |
| CORS headers | Not appropriate | ✅ Middleware (pre-routing) |
| Audit logging | ✅ Action filter (controller context) | Middleware (basic request log) |
| Response caching | ✅ Resource filter (action-level) | Middleware (path-level) |
Common Mistakes
Mistake 1 — Registering a filter at global scope AND at action level (runs twice)
❌ Wrong — global LogActionAttribute runs, then [LogAction] on the action runs again for the same request.
✅ Correct — choose one scope per filter; use [SkipFilter] or IOrderedFilter to handle exceptions.
Mistake 2 — Using filters for concerns that should be middleware
❌ Wrong — CORS headers in a result filter (does not run for non-MVC requests like static files or health checks).
✅ Correct — CORS, compression, and security headers in middleware for complete coverage.