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
});
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.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.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.