- Published on
Endpoint Filters in ASP.NET Core Minimal APIs
- Authors
- Name
- Ivan Gechev
Introduction
Every non-trivial API needs cross-cutting logic: logging, validation, error handling, authorization checks. In controller-based APIs we reach for action filters. In ASP.NET Core Minimal APIs, the equivalent mechanism is the endpoint filter.
Endpoint filters let us intercept a request before our handler runs and shape the response after it completes — or short-circuit the entire pipeline entirely.
In this article we'll look at how endpoint filters work in ASP.NET Core, how to write them, and when they're the right tool.
How Endpoint Filters Work in ASP.NET Core
An endpoint filter wraps around our handler invocation. The runtime gives each filter a chance to inspect the request context, call the next item in the pipeline (which might be another filter or the handler itself), and then inspect or transform the result.
The core interface provided in .NET is called IEndpointFilter:
public interface IEndpointFilter
{
ValueTask<object?> InvokeAsync(
EndpointFilterInvocationContext context,
EndpointFilterDelegate next);
}
The EndpointFilterInvocationContext carries the HttpContext and the strongly-typed arguments that will be passed to the handler. The EndpointFilterDelegate, on the other hand, is the next step in the request pipeline. This is what makes endpoint filters more powerful than middleware for endpoint-specific logic — we have direct access to the handler's typed arguments via context.GetArgument<T>(), something middleware can't do.
Setting Up the Example
As usual, we'll use a Cat API throughout. Our domain types:
public record Cat(int Id, string Name, string Breed, int Age);
public record CreateCatRequest(string Name, string Breed, int Age);
We also add a minimal in-memory store:
public static class CatStore
{
public static readonly List<Cat> Cats =
[
new(1, "Whiskers", "Persian", 3),
new(2, "Shadow", "British Shorthair", 5),
new(3, "Luna", "Maine Coon", 2),
];
}
Writing an Endpoint Filter in Minimal APIs
Class-Based Filters
The cleanest approach for reusable logic is to implement the IEndpointFilter interface in a class:
public class RequestLoggingFilter(ILogger<RequestLoggingFilter> logger)
: IEndpointFilter
{
public async ValueTask<object?> InvokeAsync(
EndpointFilterInvocationContext context,
EndpointFilterDelegate next)
{
logger.LogInformation(
"Handling {Method} {Path}",
context.HttpContext.Request.Method,
context.HttpContext.Request.Path);
var result = await next(context);
logger.LogInformation(
"Completed {Method} {Path}",
context.HttpContext.Request.Method,
context.HttpContext.Request.Path);
return result;
}
}
Then, we can add it to our endpoint using the AddEndpointFilter<T>() method:
app.MapGet("/cats", () => {
return Results.Ok(CatStore.Cats);
})
.AddEndpointFilter<RequestLoggingFilter>();
If you want a deeper dive into this approach, you might want to check out my article on building validation filters.
Inline Filters
For one-off logic we might not want a full class, so we can pass a delegate directly:
app.MapGet("/cats", () => Results.Ok(CatStore.Cats))
.AddEndpointFilter(async (context, next) =>
{
Console.WriteLine("Before handler");
var result = await next(context);
Console.WriteLine("After handler");
return result;
});
Short-Circuiting the Pipeline
A filter can return a result directly without calling next, which prevents the handler from running at all. This is useful for validation or authorization checks:
public class ApiKeyAuthenticationFilter : IEndpointFilter
{
private const string ApiKeyHeader = "X-Api-Key";
private readonly byte[][] _validApiKeys =
Environment.GetEnvironmentVariable("CATS_API_VALID_API_KEYS")?
.Split(',', StringSplitOptions.RemoveEmptyEntries)
.Select(Encoding.UTF8.GetBytes)
.ToArray()
?? [];
public async ValueTask<object?> InvokeAsync(
EndpointFilterInvocationContext context,
EndpointFilterDelegate next)
{
string? apiKey = context.HttpContext.Request.Headers[ApiKeyHeader];
if (!IsApiKeyValid(apiKey))
{
return Results.Unauthorized();
}
return await next(context);
}
}
If the header is missing or wrong, our handler will never execute. A deeper dive can be found in my API key authentication post.
Applying Minimal API Endpoint Filters at Different Scopes
Single Endpoint
app.MapPost("/cats", (CreateCatRequest request) =>
{
var cat = new Cat(
CatStore.Cats.Max(c => c.Id) + 1,
request.Name,
request.Breed,
request.Age);
CatStore.Cats.Add(cat);
return Results.Created($"/cats/{cat.Id}", cat);
})
.AddEndpointFilter<ValidationFilter<CreateCatRequest>>();
Route Group
Applying a filter to a route group makes it run for every endpoint in the group:
var cats = app.MapGroup("/cats")
.AddEndpointFilter<RequestLoggingFilter>();
cats.MapGet("", () => Results.Ok(CatStore.Cats));
cats.MapDelete("", () => Results.NoContent());
In this case, calls to both of our endpoints will be logged.
Chaining Multiple Filters
We can chain filters — they will always run in the order they're registered, wrapping each other like middleware:
app.MapPost("/cats", CreateCat)
.AddEndpointFilter<RequestLoggingFilter>()
.AddEndpointFilter<ApiKeyAuthenticationFilter>()
.AddEndpointFilter<ValidationFilter<CreateCatRequest>>();
Execution order:
RequestLoggingFilter— logs before and afterApiKeyAuthenticationFilter— checks the header, may short-circuitValidationFilter<CreateCatRequest>— validates the body, may short-circuit- Endpoint handler — runs if all filters pass
Endpoint Filters vs Middleware in ASP.NET Core
It's worth being clear about when to use endpoint filters versus middleware:
| Feature | Middleware | Endpoint Filters |
|---|---|---|
| Scope | Global (all requests) | Per endpoint or route group |
| Pipeline position | Wraps the routing pipeline | After routing, before handler |
| Access to typed arguments | ❌ | ✅ |
| Short-circuiting | ✅ | ✅ |
| Typical examples | CORS, HTTPS redirection, exceptions | Validation, fine-grained auth |
| Works with controllers | ✅ | ❌ (Minimal APIs only) |
You should use filters when the logic is endpoint-specific. Opt for middleware when it applies to every request. The key differentiator is access to typed handler arguments — middleware has no way to inspect or validate a handler's parameters directly, which is exactly where filters shine. If you find yourself reaching for middleware to handle something that only applies to one or two endpoints, an endpoint filter is almost certainly the better fit.
Conclusion
Endpoint filters give us a clean, composable way to handle cross-cutting concerns in ASP.NET Core Minimal APIs. We can log requests, validate inputs, enforce API keys, or run any other pre/post-handler logic — and apply it to individual endpoints or entire groups without touching the handler itself.
The ability to access strongly-typed handler arguments through context.GetArgument<T>() is what sets filters apart from middleware and makes them the right place for input validation in a Minimal API.
Frequently Asked Questions
Can an endpoint filter run asynchronously?
Yes. The InvokeAsync method returns a ValueTask<object?>, so we can freely await any async operations inside it.
Can filters access services from the DI container?
Yes. Class-based filters are resolved from the DI container, so we can inject any registered service through the constructor.
What happens if multiple filters all short-circuit?
The first filter that returns without calling next wins. Subsequent filters and the handler never execute.
Are endpoint filters a replacement for FluentValidation?
No — they're a placement mechanism. We can run FluentValidation (or any validation library) inside a filter. The filter decides when and how to invoke the validator.
Do endpoint filters work with route groups created via MapGroup?
Yes. Filters added to a route group apply to all endpoints registered within that group.
What is the difference between endpoint filters and action filters in ASP.NET Core?
Action filters are part of the MVC/controller pipeline. Endpoint filters are their Minimal APIs equivalent — they sit in the endpoint routing pipeline and have access to the same HttpContext, but they also expose the handler's typed arguments, which action filters don't.