.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.

C#
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:

C#
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:

C#
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.

C#
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.

C#
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

C#
builder.Services.AddControllers(options =>
{
    options.Filters.Add<RequestLoggingFilter>();
    options.Filters.Add<ApiExceptionFilter>();
});

Per-controller

C#
[ApiController]
[Route("api/orders")]
[ServiceFilter(typeof(RequestLoggingFilter))]
public class OrdersController : ControllerBase { }

Per-action

C#
[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:

C#
[TypeFilter(typeof(RequireRoleFilter), Arguments = new object[] { "Admin" })]
public IActionResult DeleteUser(int id) { }
C#
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:

C#
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.