Introduction

HTTP requests flow through multiple layers before reaching your API endpoint. Middleware intercepts every request and response at the application level, but operates too early to access your controller context and route data.

Filters intercept requests and responses at the MVC layer, between routing and your action methods. They access the controller, route values, and action metadata that middleware cannot reach. Move repetitive authorization checks, validation logic, and exception handling out of your controllers by centralizing them in filters.

What Filters Do in .NET

Filters in ASP.NET Core run after routing completes but before your action method executes. At this point they access the controller instance, action metadata, route values, and model-binding results that middleware never sees.

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.

HTTP Request

Routing

AUTHORIZATION FILTER (first)

RESOURCE FILTER

Action Method Execution
(+ MODEL BINDING)

ACTION FILTER (OnActionExecuting)

Controller Action Executes

ACTION FILTER (OnActionExecuted)

EXCEPTION FILTER
(if unhandled exception)

RESULT FILTER

Response Sent

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.

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

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 runs early, before routing, without access to controller context. Filters run after routing with full access to controller and action metadata.

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.