Filter Pipeline, Order and Cross-Cutting Concerns

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
});
Note: Filter scopes affect the default execution order within the same filter type. Global filters run outermost (first on the way in, last on the way out), controller-level filters run next, and action-level filters run innermost (closest to the action). Within the same scope, filters run in registration order. For fine-grained control, implement 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.
Tip: Choose between filters and middleware with this rule: if the concern is MVC-specific (needs access to the controller, action descriptor, model state, or action result), use a filter. If the concern is HTTP-level (applies to all requests, not just MVC actions), use middleware. Security headers: middleware (applies to every response including static files). API key validation: filter if applied to specific controller actions; middleware if applied to all routes. Audit logging: filter if you need action context (controller name, action parameters); middleware for basic request/response logging.
Warning: Registering the same filter at multiple scopes (global + controller + action) causes it to run multiple times. For example, a global 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.

🧠 Test Yourself

An authorization filter short-circuits a request. Which subsequent filters still run?