Stop Repeating Auth Logic — Add Action & Exception Filters
Filters let you run code before and after controller actions without touching every method. Learn IActionFilter, IExceptionFilter, IAuthorizationFilter, and how to wire them up globally, per-controller, and per-action.
What Filters Are and Where They Run
Filters run inside the MVC/Minimal API pipeline — after routing, after model binding, but before (and after) your action executes. They are not middleware. Middleware wraps the entire request; filters wrap action execution.
The pipeline order:
Request → Middleware → Routing → Authorization filters
→ Resource filters → Model Binding
→ Action filters → Action → Action filters (after)
→ Result filters → Result → Result filters (after)
→ Exception filters (if unhandled exception)Each filter type has a specific job. Pick the right one.
IActionFilter — Logging and Validation
IActionFilter runs before and after action execution. Perfect for logging, timing, or rejecting requests with bad input.
public class RequestLoggingFilter : IActionFilter
{
private readonly ILogger<RequestLoggingFilter> _logger;
private Stopwatch _stopwatch = default!;
public RequestLoggingFilter(ILogger<RequestLoggingFilter> logger)
{
_logger = logger;
}
public void OnActionExecuting(ActionExecutingContext context)
{
_stopwatch = Stopwatch.StartNew();
_logger.LogInformation(
"Action {Action} starting with args {@Args}",
context.ActionDescriptor.DisplayName,
context.ActionArguments);
}
public void OnActionExecuted(ActionExecutedContext context)
{
_stopwatch.Stop();
_logger.LogInformation(
"Action {Action} finished in {Elapsed}ms",
context.ActionDescriptor.DisplayName,
_stopwatch.ElapsedMilliseconds);
}
}Short-circuit the request from OnActionExecuting by setting context.Result:
public void OnActionExecuting(ActionExecutingContext context)
{
if (!context.ModelState.IsValid)
{
context.Result = new UnprocessableEntityObjectResult(
new ValidationProblemDetails(context.ModelState));
}
}Setting context.Result stops execution — the action never runs.
IAsyncActionFilter — Async Version
When your filter needs to call async code (e.g., a database check), use IAsyncActionFilter:
public class EnsureResourceExistsFilter<T> : IAsyncActionFilter
where T : class
{
private readonly AppDbContext _db;
public EnsureResourceExistsFilter(AppDbContext db)
{
_db = db;
}
public async Task OnActionExecutionAsync(
ActionExecutingContext context,
ActionExecutionDelegate next)
{
if (context.ActionArguments.TryGetValue("id", out var idObj)
&& idObj is int id)
{
var exists = await _db.Set<T>().AnyAsync(e =>
EF.Property<int>(e, "Id") == id);
if (!exists)
{
context.Result = new NotFoundResult();
return; // do NOT call next()
}
}
await next(); // execute the action
}
}Always either set context.Result OR call next() — never both, never neither.
IExceptionFilter — Catch Unhandled Exceptions
IExceptionFilter fires when an unhandled exception escapes an action. It does not replace a global exception handler, but it is useful for controller-scoped error handling.
public class ApiExceptionFilter : IExceptionFilter
{
private readonly ILogger<ApiExceptionFilter> _logger;
public ApiExceptionFilter(ILogger<ApiExceptionFilter> logger)
{
_logger = logger;
}
public void OnException(ExceptionContext context)
{
_logger.LogError(context.Exception, "Unhandled exception in action");
var problem = context.Exception switch
{
NotFoundException ex => new ProblemDetails
{
Status = StatusCodes.Status404NotFound,
Title = "Not Found",
Detail = ex.Message
},
ValidationException ex => new ProblemDetails
{
Status = StatusCodes.Status400BadRequest,
Title = "Validation Failed",
Detail = ex.Message
},
_ => new ProblemDetails
{
Status = StatusCodes.Status500InternalServerError,
Title = "An error occurred"
}
};
context.Result = new ObjectResult(problem)
{
StatusCode = problem.Status
};
context.ExceptionHandled = true; // stop exception propagation
}
}Set context.ExceptionHandled = true or the exception will continue bubbling.
IAuthorizationFilter — Custom Auth Rules
IAuthorizationFilter runs before everything else, including model binding. Use it for custom rules that the built-in [Authorize] attribute cannot express.
public class RequireApiKeyFilter : IAuthorizationFilter
{
private readonly string _apiKey;
public RequireApiKeyFilter(IConfiguration config)
{
_apiKey = config["ApiKey"]
?? throw new InvalidOperationException("ApiKey not configured");
}
public void OnAuthorization(AuthorizationFilterContext context)
{
if (!context.HttpContext.Request.Headers
.TryGetValue("X-Api-Key", out var key)
|| key != _apiKey)
{
context.Result = new UnauthorizedObjectResult(new ProblemDetails
{
Status = StatusCodes.Status401Unauthorized,
Title = "Invalid or missing API key"
});
}
}
}If context.Result is set here, the action, action filters, and result filters are all skipped.
Registering Filters
Globally — applies to every action
builder.Services.AddControllers(options =>
{
options.Filters.Add<RequestLoggingFilter>();
options.Filters.Add<ApiExceptionFilter>();
});Per-controller
[ApiController]
[Route("api/orders")]
[ServiceFilter(typeof(RequestLoggingFilter))]
public class OrdersController : ControllerBase { }Per-action
[HttpGet("{id}")]
[ServiceFilter(typeof(EnsureResourceExistsFilter<Order>))]
public async Task<IActionResult> GetOrder(int id) { }TypeFilter vs ServiceFilter
Both let you use DI-injected filters as attributes, but they work differently.
TypeFilter — instantiates the filter itself, passing extra constructor args:
[TypeFilter(typeof(RequireRoleFilter), Arguments = new object[] { "Admin" })]
public IActionResult DeleteUser(int id) { }public class RequireRoleFilter : IAuthorizationFilter
{
private readonly string _role;
public RequireRoleFilter(string role) // non-DI argument
{
_role = role;
}
public void OnAuthorization(AuthorizationFilterContext context)
{
if (!context.HttpContext.User.IsInRole(_role))
context.Result = new ForbidResult();
}
}ServiceFilter — resolves the filter from DI directly. The filter must be registered in the container:
builder.Services.AddScoped<RequestLoggingFilter>();
[ServiceFilter(typeof(RequestLoggingFilter))]
public IActionResult GetOrders() { }Use ServiceFilter when your filter has only DI dependencies. Use TypeFilter when you need to pass extra non-DI values like a role name or a key.
Quick Reference
| Filter | Runs | Use For |
|---|---|---|
| IAuthorizationFilter | First | Custom auth/API key checks |
| IResourceFilter | After auth, before binding | Caching, short-circuiting |
| IActionFilter | Before/after action | Logging, validation, timing |
| IExceptionFilter | On unhandled exception | Exception-to-response mapping |
| IResultFilter | Before/after result | Response transformation |
Filters are the right tool for concerns that belong inside the MVC layer. For concerns that apply to every request (static files, auth middleware, CORS), stick with middleware.
Enjoyed this article?
Explore the Backend Systems learning path for more.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.