Action Filters — OnActionExecuting and OnActionExecuted

MVC filters are components that run at specific points in the action execution pipeline — before the action, after the action, around the result, or when an exception occurs. Unlike middleware (which runs for every HTTP request), filters run only for MVC actions and have full access to the action’s context: the controller, the action descriptor, model binding results, and the action result. Filters are the right tool for cross-cutting concerns that are specific to MVC actions: audit logging, input validation that needs controller context, response header injection, and per-action caching.

Action Filter Fundamentals

// ── Attribute-based action filter ─────────────────────────────────────────
public class LogActionAttribute : ActionFilterAttribute
{
    // OnActionExecuting — runs BEFORE the action method
    public override void OnActionExecuting(ActionExecutingContext context)
    {
        var controllerName = context.RouteData.Values["controller"];
        var actionName     = context.RouteData.Values["action"];
        var userId         = context.HttpContext.User.Identity?.Name ?? "anonymous";

        context.HttpContext.Items["ActionStartTime"] = DateTime.UtcNow;
        // context.HttpContext.RequestServices available for DI resolution

        base.OnActionExecuting(context);  // continue pipeline
    }

    // OnActionExecuted — runs AFTER the action method
    public override void OnActionExecuted(ActionExecutedContext context)
    {
        var startTime = (DateTime?)context.HttpContext.Items["ActionStartTime"];
        var elapsed   = startTime.HasValue
            ? (DateTime.UtcNow - startTime.Value).TotalMilliseconds
            : 0;

        var actionName = context.RouteData.Values["action"];
        // Log: action, elapsed, result type, any exception
        base.OnActionExecuted(context);
    }
}

// ── Apply as attribute ─────────────────────────────────────────────────────
[LogAction]
public class PostsController : Controller
{
    [LogAction]   // also per-action
    public async Task<IActionResult> Details(int id) => View();
}

// ── Async filter with DI (use TypeFilter or ServiceFilter) ────────────────
public class AuditFilter(IAuditService auditService) : IAsyncActionFilter
{
    public async Task OnActionExecutionAsync(
        ActionExecutingContext context,
        ActionExecutionDelegate next)   // calling next() executes the action
    {
        var action  = context.ActionDescriptor.DisplayName;
        var userId  = context.HttpContext.User.FindFirstValue(ClaimTypes.NameIdentifier);

        // Before: log the attempt
        await auditService.LogAsync(new AuditEntry
        {
            UserId    = userId,
            Action    = action,
            Timestamp = DateTime.UtcNow,
            IpAddress = context.HttpContext.Connection.RemoteIpAddress?.ToString(),
        });

        // Execute the action
        var executedContext = await next();

        // After: update with result
        await auditService.UpdateResultAsync(action, executedContext.Exception is null);
    }
}

// ── Register DI-injected filter globally ──────────────────────────────────
builder.Services.AddScoped<AuditFilter>();
builder.Services.AddControllersWithViews(opts =>
{
    opts.Filters.AddService<AuditFilter>();  // global, DI-resolved
});
Note: There are two ways to use DI in filters: ServiceFilter and TypeFilter. [ServiceFilter(typeof(AuditFilter))] requires the filter to be registered in the DI container (as Scoped, Transient, or Singleton). [TypeFilter(typeof(AuditFilter))] creates a new instance of the filter per request using the DI container for constructor injection but does not require the filter itself to be registered. For most cases, ServiceFilter with a Scoped registration is the cleanest pattern — it integrates with DI lifetimes correctly and is testable.
Tip: To short-circuit the pipeline from OnActionExecuting (prevent the action from running), set context.Result to an IActionResult. The pipeline stops and the result is executed directly: context.Result = new RedirectToActionResult("Login", "Account", null). This is how authorization filters work — they set a ForbidResult or UnauthorizedResult to stop the action from executing. Once context.Result is set in OnActionExecuting, OnActionExecuted still runs but context.Canceled is true.
Warning: Do not use ActionFilterAttribute (synchronous) when your filter needs to call async methods — there is no await in the synchronous override. Instead, implement IAsyncActionFilter and use the OnActionExecutionAsync method which properly supports async/await. Mixing synchronous filters with async work (using .Result or .Wait()) causes the same deadlock and thread-pool exhaustion risks as any other blocked async operation in ASP.NET Core.

Filter Registration Scopes

Scope How to Register Applies To
Global opts.Filters.Add() in AddControllersWithViews All controllers and actions
Controller [FilterAttribute] on the controller class All actions in that controller
Action [FilterAttribute] on a specific action method That action only

Common Mistakes

Mistake 1 — Using synchronous filter overrides with async database calls

❌ Wrong — .Result deadlocks in async context:

public override void OnActionExecuting(ActionExecutingContext ctx)
{
    _service.SaveAsync().Result;   // deadlock risk!
}

✅ Correct — implement IAsyncActionFilter with async/await.

Mistake 2 — Not calling next() in OnActionExecutionAsync (action never executes)

❌ Wrong — returning without calling await next() silently stops the action:

public async Task OnActionExecutionAsync(ActionExecutingContext ctx, ActionExecutionDelegate next)
{
    await DoSomethingAsync();
    // forgot await next() — action never runs, returns empty 200!

✅ Correct — always var result = await next(); unless intentionally short-circuiting.

🧠 Test Yourself

How do you prevent an action from executing inside OnActionExecuting?