HTTP requests flow through multiple layers before reaching your API endpoint. Middleware intercepts requests and responses at the application level. Its access to routing information depends on where it runs: middleware placed after route matching can read the selected endpoint and route values, but it does not receive MVC-specific controller, action-argument, or model-binding contexts.

Filters intercept requests and responses inside the MVC action invocation pipeline. They expose strongly typed contexts for the selected controller action, bound action arguments, model state, results, and filter-specific short-circuiting. Move MVC-specific cross-cutting logic out of controllers by centralizing it in filters.

What Filters Do in .NET

Filters in ASP.NET Core run after ASP.NET Core selects the action to execute. Depending on the filter stage, they can access the controller instance, action metadata, route values, model state, bound arguments, and action result. Middleware can access HttpContext, endpoint metadata, and route values when ordered appropriately, but not these MVC filter contexts.

Why Filters Matter

Reusability. Write once, apply everywhere.

Modularity. Authorization logic lives in one place, not scattered across actions.

Maintainability. Change the filter once. The change applies to all registrations.

Filter Execution Pipeline

Filters run in a predictable order. Knowing this determines how you use them.

ASP.NET Core filter execution pipeline A request passes through routing, then the authorization and resource filters, then model binding, the action filter before and after the controller action, the exception filter if anything throws, the result filter, and finally the response. The five filter stages are highlighted; the other stages are ordinary pipeline steps. HTTP request Routing Model binding Controller action executes Response sent Authorization filter · runs first Resource filter Action filter · OnActionExecuting Action filter · OnActionExecuted Exception filter · if it throws Result filter

Filter Execution Order

Filters execute in a nested sequence. Global filters wrap controller filters, which wrap action filters. Executing filters run in order; after-execution handlers run in reverse.

ScopeOn ExecutionOn Completion
GlobalAuthorization and validationCleanup and logging
ControllerController-level checksController-level cleanup
ActionAction-specific logicAction-level cleanup

Example: Nested Filter Execution

Register filters at all three levels and a single request flows: Global → Controller → Action → execute → Action (reverse) → Controller (reverse) → Global (reverse).

Six Built-In Filter Types

ASP.NET Core provides six filter types, each at a different pipeline stage.

Filter TypeWhen it runsUse it for
AuthorizationBefore action (first)Check user permissions, validate API keys (IAsyncAuthorizationFilter)
ResourceAfter auth, before bindingValidate resources, short-circuit (IAsyncResourceFilter)
ActionAfter binding, before/after actionLogging, transformation, validation (IAsyncActionFilter)
ExceptionWhen unhandled exception occursCentralized error handling (IAsyncExceptionFilter)
ResultAfter action, before serializationModify headers, transform response (IAsyncResultFilter)
EndpointMinimal API entry pointValidation, logging (IEndpointFilter)

Authorization Filter

IAsyncAuthorizationFilter runs first, immediately after routing. If authorization fails, short-circuit the request and prevent all further processing.

Use it to check user permissions, validate API keys, or enforce security policies before the action runs.

public sealed class ApiKeyAuthorizationFilter(
    DemoAuditLog auditLog,
    IConfiguration configuration)
    : IAsyncAuthorizationFilter
{
    private const string ApiKeyHeader = "X-Api-Key";

    public async Task OnAuthorizationAsync(AuthorizationFilterContext context)
    {
        var suppliedKey = context.HttpContext.Request.Headers[ApiKeyHeader].ToString();
        var expectedKey = configuration["DemoApiKey"];

        if (string.IsNullOrEmpty(expectedKey) || !KeysMatch(suppliedKey, expectedKey))
        {
            await auditLog.WriteAsync(
                "Authorization rejected the request.",
                context.HttpContext.RequestAborted);

            context.HttpContext.Response.Headers.WWWAuthenticate = "ApiKey";
            context.Result = new UnauthorizedObjectResult(new ProblemDetails
            {
                Status = StatusCodes.Status401Unauthorized,
                Title = "Unauthorized",
                Detail = $"Supply a valid API key in the {ApiKeyHeader} header.",
                Instance = context.HttpContext.Request.Path
            });
            return;
        }

        await auditLog.WriteAsync(
            "Authorization accepted the request.",
            context.HttpContext.RequestAborted);
    }

    private static bool KeysMatch(string supplied, string expected)
    {
        var suppliedBytes = Encoding.UTF8.GetBytes(supplied);
        var expectedBytes = Encoding.UTF8.GetBytes(expected);
        return CryptographicOperations.FixedTimeEquals(suppliedBytes, expectedBytes);
    }
}

The filter compares the supplied key against the configuration value using CryptographicOperations.FixedTimeEquals, which prevents timing attacks. On mismatch, return a ProblemDetails response and log the failure.

The filter reads configuration["DemoApiKey"] straight off IConfiguration. See Configuration in .NET for how that value gets there from appsettings.json, environment variables, or the command line.

Resource Filter

IAsyncResourceFilter runs second, after authorization but before model binding. It’s the first chance to access the HTTP context after auth succeeds.

Use it to validate required resources exist, measure request duration, or perform caching.

public sealed class TimingResourceFilter(DemoAuditLog auditLog) : IAsyncResourceFilter
{
    public async Task OnResourceExecutionAsync(
        ResourceExecutingContext context,
        ResourceExecutionDelegate next)
    {
        var stopwatch = Stopwatch.StartNew();

        context.HttpContext.Response.OnStarting(() =>
        {
            context.HttpContext.Response.Headers["X-Resource-Time-Ms"] =
                stopwatch.ElapsedMilliseconds.ToString();
            return Task.CompletedTask;
        });

        await auditLog.WriteAsync(
            "Resource filter: before the remaining MVC pipeline.",
            context.HttpContext.RequestAborted);

        await next();

        stopwatch.Stop();
        await auditLog.WriteAsync(
            $"Resource filter: after the pipeline ({stopwatch.ElapsedMilliseconds} ms).",
            context.HttpContext.RequestAborted);
    }
}

The OnStarting callback adds the timing header before the response writes to the network.

Action Filter

IAsyncActionFilter runs third, after model binding. It accesses the bound model, route data, controller instance, and action metadata.

Use it for validation that depends on the bound model, transformation of incoming or outgoing data, or action-specific logging.

public sealed class ValidateItemActionFilter(DemoAuditLog auditLog) : IAsyncActionFilter
{
    public async Task OnActionExecutionAsync(
        ActionExecutingContext context,
        ActionExecutionDelegate next)
    {
        await auditLog.WriteAsync(
            "Action filter: before the controller action.",
            context.HttpContext.RequestAborted);

        var request = context.ActionArguments.Values.OfType<CreateItemRequest>().FirstOrDefault();
        if (request is not null && string.IsNullOrWhiteSpace(request.Name))
        {
            context.Result = new BadRequestObjectResult(new ProblemDetails
            {
                Status = StatusCodes.Status400BadRequest,
                Title = "Validation failed",
                Detail = "Name is required (returned by the action filter).",
                Instance = context.HttpContext.Request.Path
            });
            return;
        }

        var executedContext = await next();

        await auditLog.WriteAsync(
            $"Action filter: after the action; canceled={executedContext.Canceled}.",
            context.HttpContext.RequestAborted);
    }
}

Return a BadRequestObjectResult without calling next() to short-circuit and prevent the action from executing.

Exception Filter

IAsyncExceptionFilter runs when an unhandled exception occurs. It centralizes error handling and transforms exceptions into HTTP responses.

For application-wide error handling, use middleware with IExceptionHandler or the ProblemDetails pattern instead. Middleware operates at the application level and catches errors filters cannot.

public sealed class ApiExceptionFilter(DemoAuditLog auditLog) : IAsyncExceptionFilter
{
    public async Task OnExceptionAsync(ExceptionContext context)
    {
        await auditLog.WriteAsync(
            $"Exception filter handled {context.Exception.GetType().Name}.",
            context.HttpContext.RequestAborted);

        context.Result = new ObjectResult(new ProblemDetails
        {
            Status = StatusCodes.Status500InternalServerError,
            Title = "The demo action failed.",
            Detail = context.Exception.Message,
            Instance = context.HttpContext.Request.Path
        })
        {
            StatusCode = StatusCodes.Status500InternalServerError
        };

        context.ExceptionHandled = true;
    }
}

Set ExceptionHandled = true to mark the exception as handled and prevent propagation.

Result Filter

IAsyncResultFilter runs after the action executes and you create the result, before serialization and response. It inspects or modifies response headers and the action result itself.

Use IAsyncAlwaysRunResultFilter if your filter must run even when a prior filter short-circuits.

This example adds a custom response header before the result is serialized:

public sealed class ResponseHeaderResultFilter(DemoAuditLog auditLog) : IAsyncResultFilter
{
    public async Task OnResultExecutionAsync(
        ResultExecutingContext context,
        ResultExecutionDelegate next)
    {
        context.HttpContext.Response.Headers["X-Result-Filter"] = "executed";

        await auditLog.WriteAsync(
            "Result filter: before serializing the action result.",
            context.HttpContext.RequestAborted);

        var executedContext = await next();

        await auditLog.WriteAsync(
            $"Result filter: after the result; canceled={executedContext.Canceled}.",
            context.HttpContext.RequestAborted);
    }
}

Add response headers, modify content type, or transform the serialized response.

Endpoint Filter

IEndpointFilter is for Minimal APIs. It runs before and after the endpoint and accesses its arguments and result.

Use it with Minimal APIs to validate arguments, log requests, or modify the response. Controllers use action filters instead. For argument validation specifically, .NET 10 also added built-in Minimal API validation as an alternative to writing it by hand in a filter.

This example validates that a required name parameter is provided:

public sealed class RequiredNameEndpointFilter(DemoAuditLog auditLog) : IEndpointFilter
{
    public async ValueTask<object?> InvokeAsync(
        EndpointFilterInvocationContext context,
        EndpointFilterDelegate next)
    {
        var name = context.GetArgument<string?>(0);
        if (string.IsNullOrWhiteSpace(name))
        {
            await auditLog.WriteAsync(
                "Endpoint filter rejected an empty name.",
                context.HttpContext.RequestAborted);

            return TypedResults.Problem(new ProblemDetails
            {
                Status = StatusCodes.Status400BadRequest,
                Title = "Validation failed",
                Detail = "Query-string parameter 'name' is required.",
                Instance = context.HttpContext.Request.Path
            });
        }

        await auditLog.WriteAsync(
            "Endpoint filter: before the Minimal API handler.",
            context.HttpContext.RequestAborted);

        var result = await next(context);

        await auditLog.WriteAsync(
            "Endpoint filter: after the Minimal API handler.",
            context.HttpContext.RequestAborted);

        return result;
    }
}

Registering Filters

Global Filter Registration

Register globally in Program.cs to apply to every action:

builder.Services.AddControllers(options =>
{
    options.Filters.Add<ApiKeyAuthorizationFilter>();
    options.Filters.Add<ApiExceptionFilter>();
    options.Filters.Add<ResponseHeaderResultFilter>();
});

Every request passes through these filters.

Alternatively, use dependency injection:

builder.Services.AddControllers(options =>
{
    options.Filters.AddServiceFilter<ApiKeyAuthorizationFilter>();
    options.Filters.AddServiceFilter<ApiExceptionFilter>();
});

builder.Services.AddScoped<ApiKeyAuthorizationFilter>();
builder.Services.AddScoped<ApiExceptionFilter>();

Controller-Level Filter Registration

Apply to all actions in a controller using [ServiceFilter] or [TypeFilter] on the controller class:

[ServiceFilter(typeof(TimingResourceFilter))]
[ServiceFilter(typeof(ValidateItemActionFilter))]
public class ItemsController : ControllerBase
{
    [HttpPost]
    public async Task<IActionResult> CreateItem([FromBody] CreateItemRequest request) { }

    [HttpGet("{id}")]
    public async Task<IActionResult> GetItem(int id) { }
}

Both actions pass through both filters. Use [TypeFilter] to pass constructor parameters not in the dependency container:

[TypeFilter(typeof(TimingResourceFilter))]
public class ItemsController : ControllerBase { }

Action-Level Filter Registration

Apply to a single action using [ServiceFilter] or [TypeFilter] on the method:

public class ItemsController : ControllerBase
{
    [HttpPost]
    [ServiceFilter(typeof(ValidateItemActionFilter))]
    public async Task<IActionResult> CreateItem([FromBody] CreateItemRequest request) { }

    [HttpGet("{id}")]
    public async Task<IActionResult> GetItem(int id) { }
}

Filters vs. Middleware

Middleware and filters both intercept requests, but at different layers. Middleware ordering is configurable: code that runs after route matching can inspect the selected endpoint and route values, and middleware can wrap a broader part of the application than MVC alone. See Microsoft’s routing pipeline guidance.

Filters run inside MVC after action selection and provide stage-specific contexts for authorization, resource handling, bound action arguments, exceptions, and results. Use middleware for application-wide HTTP concerns; use filters when the behavior depends on MVC action semantics.

Conclusion

Filters intercept requests and responses at the MVC layer. Six built-in types run in predictable order. Register globally, per controller, or per action to control scope.

Find one piece of repetitive logic in your actions: an auth check, a validation rule, a logging statement. Move it into a filter. Combine filters with middleware and dependency injection for clean APIs.