.NET & C# Development · Lesson 11 of 92
Stop Repeating Auth Logic — Add Action & Exception Filters
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.