Skip to main content

04 - Pipeline & Filters

Every HTTP request in ASP.NET Core flows through a request pipeline - a sequence of middleware components that process the request and produce a response. Understanding this pipeline is essential for reasoning about how your application behaves: where authentication happens, when errors are caught, how routing works, and why ordering matters. After the pipeline selects an MVC action, filters provide a second layer of processing specific to controllers and actions.

Request Pipeline

  • Middleware is software that's assembled into an app pipeline to handle requests and responses
    • Chooses whether to pass the request to the next component in the pipeline
    • Can perform work before and after the next component in the pipeline
    • Modify the request and response on the way
  • Request delegates are used to build the request pipeline
    • Configured with run, map and use extension methods

Pipeline

Simplest aspnet middleware/app

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.Run(
async httpContext =>
{
await httpContext.Response.WriteAsync("Hello, World!");
}
);

app.Run();

Chain multiple request delegates together with Use. The next parameter represents the next delegate in the pipeline.

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
var rnd = new Random();

app.Use(async (context, next) =>
{
// Do work that doesn't write to the Response.
await next.Invoke();
// Do logging or other work that doesn't write to the Response.
});

app.Use(async (httpContext, next) =>
{
if (rnd.NextDouble() < 0.5)
{
await next.Invoke();
}
else
{
// short-circuit
await httpContext.Response.WriteAsync("Shortcut executed");
}
});

app.Run(
async httpContext =>
{
await httpContext.Response.WriteAsync("Hello, World!");
}
);

app.Run();

Don't call next.Invoke after the response has been sent to the client (terminal middleware).
Check httpContext.Response.HasStarted (true after response headers have been sent to client)!

Branching middleware

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.Map("/do-first", HandleDoFirst);
app.Map("/do-second", HandleDoSecond);

app.Run(async context =>
{
await context.Response.WriteAsync("<h1>Hello from non-Map delegate. </h1>");
});

app.Run();

static void HandleDoFirst(IApplicationBuilder app)
{
app.Run(async context =>
{
await context.Response.WriteAsync("First");
});
}

static void HandleDoSecond(IApplicationBuilder app)
{
app.Run(async context =>
{
await context.Response.WriteAsync("Second");
});
}

When Map is used, the matched path segments are removed from HttpRequest.Path and appended to HttpRequest.PathBase for each request.

Pipeline is configured with methods

  • Run – short circuit, return
  • Map - branch
  • Use – Chaining middleware-s together

MapWhen branches the request pipeline based on the result of the given predicate.

  • predicate of type Func<HttpContext, bool>
app.MapWhen(context => context.Request.Query.ContainsKey("test"), HandleBranch);

UseWhen also branches the request pipeline based on the result of the given predicate, but branch is rejoined to the main pipeline (if it doesn't short-circuit).

app.UseWhen(context => context.Request.Query.ContainsKey("test"),
appBuilder => HandleBranchAndRejoin(appBuilder));

Custom middleware

public class CustomMiddleware
{
private readonly RequestDelegate _next;

public CustomMiddleware(RequestDelegate next)
{
_next = next;
}

public async Task InvokeAsync(HttpContext context)
{
// Call the next delegate/middleware in the pipeline.
await _next(context);
// do something
}
}

// extension method for registering
public static class CustomMiddlewareExtensions
{
public static IApplicationBuilder UseCustomMiddleware(
this IApplicationBuilder builder)
{
return builder.UseMiddleware<CustomMiddleware>();
}
}

And inject it into pipeline

app.UseCustomMiddleware();
app.Run();

NB! Dependency injection and scoped dependencies

Middleware is singleton, use constructor DI.
For scoped and transient DI use InvokeAsync additional parameters.

Middleware ordering

Middlewares are called in order they are added. So the ordering is important/critical for several reasons - security, performance, and functionality.

app.UseExceptionHandler("/Home/Error"); // Call first to catch exceptions
// thrown in the following middleware.

app.UseStaticFiles(); // Return static files and short-circuit.

app.UseRouting(); // Match request to an endpoint.

app.UseAuthentication(); // Authenticate before you access
// secure resources.
app.UseAuthorization(); // Check the rights.

app.MapControllerRoute( // Execute the matched endpoint.
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}");

ASP.NET Middleware

Typical ASP.NET Core MVC pipeline

Pipeline

// catch errors, redirect to error page
app.UseExceptionHandler("/Error");
// redirects HTTP requests to HTTPS
app.UseHsts();
app.UseHttpsRedirection();
// returns static files and short-circuits
app.UseStaticFiles();
// GDPR
app.UseCookiePolicy();
// endpoint routing
app.UseRouting();
// authenticate user
app.UseAuthentication();
// check the rights
app.UseAuthorization();
// maintain session state
app.UseSession();
// mvc routing
app.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}");
// razor pages support
app.MapRazorPages();

Endpoint routing

UseRouting() and Map*() work together as a pair:

  • UseRouting() - marks the point where the routing decision is made (which endpoint matches the request). It does NOT execute the endpoint.
  • Map*() (e.g. MapControllerRoute, MapRazorPages) - registers and executes the matched endpoint.

Any middleware placed between UseRouting() and the endpoint execution can inspect which endpoint was selected:

app.UseRouting();

// This middleware runs AFTER routing decision but BEFORE endpoint execution
app.Use(async (context, next) =>
{
var endpoint = context.GetEndpoint();
// endpoint is null if no route matched
// endpoint?.DisplayName shows the matched route
await next();
});

app.UseAuthentication(); // can see which endpoint will run
app.UseAuthorization(); // can check [Authorize] on the matched endpoint

app.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}");

This is why UseAuthentication() and UseAuthorization() must go between routing and endpoint execution - they need to know which endpoint was matched to check its authorization requirements.

mvc-endpoint

Request lifecycle walkthrough

When an HTTP request arrives, it flows through the pipeline like this:

  1. ExceptionHandler - wraps everything in a try/catch, redirects to error page on failure
  2. HSTS / HTTPS Redirection - enforces HTTPS
  3. Static Files - if the request matches a static file (css, js, images), serve it and short-circuit (no further middleware runs)
  4. Routing (UseRouting) - examines the URL and determines which endpoint matches
  5. CORS - applies Cross-Origin Resource Sharing headers (if configured)
  6. Authentication - identifies WHO the user is (reads cookies/tokens)
  7. Authorization - checks WHETHER the user has permission for the matched endpoint
  8. Session - loads session state
  9. Endpoint execution (Map*) - runs the matched controller action or Razor page

The response then flows back through the same middleware in reverse order. Each middleware gets a chance to modify the response on the way out (e.g., adding headers, logging, compressing).

If any middleware short-circuits (doesn't call next()), the remaining middleware never executes.

Filters in ASP.NET Core

  • The filter pipeline runs after ASP.NET Core selects the action to execute

Filters

  • Filters allow code to run before or after specific stages in the request processing pipeline.

Filters

Filter types

  • Authorization filters:
    • Run first.
    • Determine whether the user is authorized for the request.
    • Short-circuit the pipeline if the request is not authorized.
  • Resource filters:
    • Run after authorization.
    • OnResourceExecuting runs code before the rest of the filter pipeline. For example, OnResourceExecuting runs code before model binding.
    • OnResourceExecuted runs code after the rest of the pipeline has completed.
  • Action filters:
    • Run immediately before and after an action method is called.
    • Can change the arguments passed into an action.
    • Can change the result returned from the action.
  • Endpoint filters:
    • Run immediately before and after an action method is called.
    • Can change the arguments passed into an action.
    • Can change the result returned from the action.
    • Can be invoked on both actions and route handler-based endpoints.
  • Result filters:
    • Run immediately before and after the execution of action results.
    • Run only when the action method executes successfully.
    • Are useful for logic that must surround view or formatter execution.
  • Exception filters:
    • Apply global policies to unhandled exceptions that occur before the response body has been written to.

Action and Endpoint filter are not supported in Razor Pages.

Middleware vs Filters - when to use which?

MiddlewareFilters
ScopeEvery HTTP requestOnly MVC/API actions
ContextHttpContext only (raw HTTP)Action context, model binding, action arguments
Knowledge of MVCNone - doesn't know about controllers or actionsFull - knows which action, its parameters, model state
Use forCross-cutting HTTP concerns: logging, CORS, authentication, static files, error handlingAction-specific logic: validation, caching results, transforming action arguments
Short-circuitDon't call next()Set context.Result in OnActionExecuting

Rule of thumb: If you need to work with raw HTTP requests/responses regardless of MVC - use middleware. If you need to work with controller actions, model binding, or action results - use filters.

Filter execution order

Filters execute in a specific order. Understanding this is critical for knowing when your code runs relative to model binding and action execution.

Order: Authorization -> Resource (before) -> Model Binding -> Action (before) -> ACTION -> Action (after) -> Exception -> Result (before) -> RESULT -> Result (after) -> Resource (after)

Scope levels (each level wraps the next):

  1. Global filters - registered in AddControllersWithViews(options => options.Filters.Add(...))
  2. Controller filters - [TypeFilter(typeof(...))] on controller class
  3. Action filters - attribute on action method

"Before" methods run Global -> Controller -> Action (outside-in). "After" methods run Action -> Controller -> Global (inside-out).

Use the Order property (default 0) to override execution order within the same scope.

Short-circuiting in filters

An action filter can prevent the action from executing by setting context.Result:

public class ValidateModelFilter : IActionFilter
{
public void OnActionExecuting(ActionExecutingContext context)
{
if (!context.ModelState.IsValid)
{
// Short-circuit - action method never runs
context.Result = new BadRequestObjectResult(context.ModelState);
}
}

public void OnActionExecuted(ActionExecutedContext context) { }
}

Filters and Attributes

Both allow injecting filters into pipeline. Injection method differs:

public class LogActionFilter : ActionFilterAttribute
{
// Filter logic here
}

[LogActionFilter]
public IActionResult Index()
{
// Controller action method logic here
}
public class LogActionFilter : IActionFilter
{}

builder.Services.AddControllersWithViews(options =>
{
// global filter
options.Filters.Add(typeof(LogActionFilter));
});

// controller level filter
[TypeFilter(typeof(LogActionFilter))]
public class HomeController : Controller
{
// Controller action methods here
}

Filter attributes to inherit from:

  • ActionFilterAttribute
  • ExceptionFilterAttribute
  • ResultFilterAttribute
  • FormatFilterAttribute
  • ServiceFilterAttribute (DI)
  • TypeFilterAttribute (DI)

Action filter

public class ActionFilter : IActionFilter
{
public void OnActionExecuting(ActionExecutingContext context)
{
// Do something before the action executes.
}

public void OnActionExecuted(ActionExecutedContext context)
{
// Do something after the action executes.
}
}

public class AsyncActionFilter : IAsyncActionFilter
{
public async Task OnActionExecutionAsync(
ActionExecutingContext context, ActionExecutionDelegate next)
{
// Do something before the action executes.
await next();
// Do something after the action executes.
}
}
  • Implement only one type of filter. When both are present, only async version is called.
  • When using abstract class ActionFilterAttribute - override only one type

Exception filter

  • Implement IExceptionFilter or IAsyncExceptionFilter.
public class ExceptionFilter : IExceptionFilter
{
private readonly IHostEnvironment _hostEnvironment;

public ExceptionFilter(IHostEnvironment hostEnvironment) =>
_hostEnvironment = hostEnvironment;

public void OnException(ExceptionContext context)
{
if (!_hostEnvironment.IsDevelopment())
{
// Don't display exception details unless running in Development.
return;
}

context.Result = new ContentResult
{
Content = context.Exception.ToString()
};
}
}

To handle an exception,

  • set the ExceptionHandled property to true

OR

  • assign the Result property.

This stops propagation of the exception. An exception filter can't turn an exception into a "success". Only an action filter can do that.

Result filter

Implement an interface:

  • IResultFilter or IAsyncResultFilter
  • IAlwaysRunResultFilter or IAsyncAlwaysRunResultFilter
public class ResultFilter : IResultFilter
{
public void OnResultExecuting(ResultExecutingContext context)
{
// Do something before the result executes.
}

public void OnResultExecuted(ResultExecutedContext context)
{
// Do something after the result executes.
}
}

Some examples

using System.Diagnostics;
using Microsoft.AspNetCore.Mvc.Filters;

public class LogActionFilter : IAsyncActionFilter
{
public async Task OnActionExecutionAsync(
ActionExecutingContext context, ActionExecutionDelegate next)
{
var stopwatch = Stopwatch.StartNew();

await next();

stopwatch.Stop();
var message = $"Action took {stopwatch.ElapsedMilliseconds} ms to execute.";
Debug.WriteLine(message);
}
}
public class ResponseHeaderAttribute : ActionFilterAttribute
{
private readonly string _name;
private readonly string _value;

public ResponseHeaderAttribute(string name, string value) =>
(_name, _value) = (name, value);

public override void OnResultExecuting(ResultExecutingContext context)
{
context.HttpContext.Response.Headers.Append(_name, _value);

base.OnResultExecuting(context);
}
}

[ResponseHeader("Filter-Header", "Filter Value")]
public class ResponseHeaderController : ControllerBase
{

}

DI example

public class LoggingResponseHeaderFilterService : IResultFilter
{
private readonly ILogger _logger;

public LoggingResponseHeaderFilterService(
ILogger<LoggingResponseHeaderFilterService> logger) =>
_logger = logger;

public void OnResultExecuting(ResultExecutingContext context)
{
_logger.LogInformation(
$"- {nameof(LoggingResponseHeaderFilterService)}.{nameof(OnResultExecuting)}");

context.HttpContext.Response.Headers.Append(
nameof(OnResultExecuting), nameof(LoggingResponseHeaderFilterService));
}

public void OnResultExecuted(ResultExecutedContext context)
{
_logger.LogInformation(
$"- {nameof(LoggingResponseHeaderFilterService)}.{nameof(OnResultExecuted)}");
}
}


builder.Services.AddScoped<LoggingResponseHeaderFilterService>();


[ServiceFilter<LoggingResponseHeaderFilterService>]
public IActionResult WithServiceFilter() =>
Content($"- {nameof(FilterDependenciesController)}.{nameof(WithServiceFilter)}");

What to Know for Code Defense

Be prepared to explain:

  1. Why does middleware ordering matter? — Middleware executes in registration order. Placing authentication after authorization means authorization always sees an anonymous user. Incorrect ordering causes security holes or broken functionality.
  2. What is short-circuiting and when is it used? — Short-circuiting means a middleware does not call next(), so subsequent middleware never runs. Static file middleware short-circuits when it finds a matching file. Authorization filters short-circuit when a user lacks permission.
  3. What is the difference between middleware and filters? — Middleware operates on every HTTP request with only HttpContext available. Filters run only for MVC/API actions and have access to action context, model binding, and action arguments. Use middleware for cross-cutting HTTP concerns; use filters for action-specific logic.
  4. Why do UseRouting() and Map*() work as a pair?UseRouting() makes the routing decision (which endpoint matches) but does not execute it. Map*() executes the matched endpoint. Middleware placed between them can inspect the matched endpoint to enforce security before execution.
  5. How does the filter execution order work? — Filters run: Authorization, Resource (before), Model Binding, Action (before), ACTION, Action (after), Exception, Result (before), RESULT, Result (after), Resource (after). "Before" methods run outside-in; "after" methods run inside-out.
  6. Why is middleware singleton but filters can be scoped? — Middleware is instantiated once at startup and shared across all requests. Filters created via [ServiceFilter] or [TypeFilter] are resolved from DI per request, allowing scoped dependencies.
  7. How do you create custom middleware? — Implement a class with a constructor accepting RequestDelegate and an InvokeAsync(HttpContext) method. Register it via an extension method on IApplicationBuilder. The class is singleton, so avoid mutable instance state.