Advanced Track
Middleware, Filters & Custom Attributes
By the end of this lesson you'll understand how an ASP.NET Core request flows through the middleware pipeline — why ordering matters, how a step can let a request pass or short-circuit it, and how to write your own middleware, filters, and attributes for cross-cutting concerns like logging, auth, and error handling.
What You'll Learn
- How the middleware pipeline runs requests in, then responses out (and why)
- Why ordering matters — and which steps must come first
- Use, Run and Map: passthrough, terminal, and branching steps
- Short-circuiting: stopping a request before it reaches the handler
- Writing a custom middleware class for every-request concerns
- Action filters and custom attributes for targeted, declarative work
HttpContext, and how a request maps to a response — because middleware is the layer that wraps every one of those requests.💡 Real-World Analogy
The middleware pipeline is like the airport security checkpoints a passenger passes through: ticket check, then bag scan, then passport control, then the gate. Each checkpoint can wave you through, modify you (make you take your belt off), or stop you completely. The order is fixed — you can't reach the gate before passport control. And on the way back out you pass the same posts in reverse. An ASP.NET request flows through middleware exactly like that: in through each step, to the handler at the gate, then back out through each step in reverse.
What "middleware" actually is
A cross-cutting concern is a job that applies to lots of requests rather than to one feature — logging, authentication, compression, error handling, CORS. You don't want to copy that code into every controller. Middleware is where it lives: a chain of small components that each get a turn at the request before (and after) your handler runs.
Each component is handed next — a delegate representing the rest of the pipeline. The component runs some code, optionally calls next to pass the request along, and then runs more code once the response comes back. Calling next makes it a passthrough; not calling it short-circuits the request.
- 🧱 Middleware — runs for every request (logging, auth, errors, CORS)
- 🎯 Filters — run only around MVC actions (model validation, audit)
- 🏷️ Attributes — filter logic attached declaratively with
[BracketSyntax]
ASP.NET can't run in this in-browser sandbox, so the ASP.NET examples below are read-only with their console output shown in // ✅ Expected output comments. The 🎯 Your Turn exercises model the exact same pipeline idea in plain C# that does run here, so you can feel the in/out flow for yourself.
1. The Pipeline & Why Order Matters
A request flows down through each middleware in the order you register them, hits the terminal handler, then flows back up in reverse. The code before await next() runs on the way in; the code after it runs on the way out. That nesting is why a step registered first wraps everything after it — so error-handling and logging go first, and authentication goes before anything that needs to know who the user is. Read this worked example and trace the output.
Worked example: the request-in / response-out pipeline
ASP.NET (read-only here). Trace how the OUT order reverses the IN order.
// Program.cs — the ASP.NET Core middleware pipeline.
// Each app.Use(...) adds ONE checkpoint the request passes through, in order.
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
// app.Use(...) = a PASSTHROUGH step. It takes 'next' (the rest of the
// pipeline) and MUST call it to let the request continue.
app.Use(async (context, next) =>
{
Console.WriteLine("1) Logging → request in"); /
...2. Use, Run, Map & Short-Circuiting
There are a few verbs for building the pipeline. app.Use adds a passthrough step that receives next and should call it. app.Run adds a terminal step with no next — it ends the pipeline. app.Map (and MapWhen/UseWhen) branches the pipeline based on the path or a predicate. And any step can short-circuit: by simply not calling next (e.g. returning a 403), it stops the request before it reaches the handler.
Worked example: Use vs Run vs Map, and short-circuiting
ASP.NET (read-only here). See branching and an early 403 that skips the handler.
// Use / Run / Map / UseWhen — the four pipeline-building verbs.
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
var app = WebApplication.CreateBuilder(args).Build();
// SHORT-CIRCUIT: this step decides NOT to call next() for blocked paths,
// so the rest of the pipeline (and the handler) never runs.
app.Use(async (context, next) =>
{
if (context.Request.Path.StartsWithSegments("/blocked"))
{
context.Response.StatusCode = 403;
await context.Response
...3. Build a Pipeline Yourself
Middleware is really just a chain of responsibility: each step holds a reference to the next and decides whether to call it. You can model that in plain C# with delegates — and unlike the ASP.NET examples, this code runs right here. Fill in the three ___ blanks to wire a Logging step around an Auth step around the handler, then run it and watch the in/out order.
🎯 Your turn: a 2-step delegate pipeline
Wire Logging → Auth → Handler with Action delegates, then run it.
using System;
// This is the SAME pattern as ASP.NET middleware, written in plain C# so it
// runs here: each step does work, then calls next() to pass control along.
class Program
{
// A step takes 'next' (the rest of the chain) and returns a runnable step.
static Action Step(string name, Action next) => () =>
{
Console.WriteLine($"{name} → in");
next(); // hand off to the next step (passthrough)
Console.WriteLine($"{name} ← out");
...Now make a step short-circuit. The gate below checks for an API key; when it's missing it should print the blocked message and stop — without calling the handler. Fill in the one blank so the request never reaches the handler.
🎯 Your turn: short-circuit the pipeline
Add the early return so a missing API key blocks the handler.
using System;
// A step can DECIDE not to call next() — that "short-circuits" the pipeline,
// exactly like middleware that returns 401 without reaching the handler.
class Program
{
static Action Step(string name, Action next) => () =>
{
Console.WriteLine($"{name} → in");
next();
Console.WriteLine($"{name} ← out");
};
static void Main()
{
// 🎯 YOUR TURN — add an auth gate that blocks the request.
bool hasApiKey = false; // pretend
...4. Custom Middleware, Filters & Attributes
For anything beyond a one-liner, write a middleware class: its constructor captures next (a RequestDelegate) and its InvokeAsync(HttpContext) runs per request. Filters are more targeted — they run only around MVC controller actions, so use them for concerns scoped to controllers (model validation, auditing) rather than every static file or health check. A custom attribute is filter logic you attach declaratively, like [RequireApiKey], keeping security out of your business code.
Worked example: middleware class + filter + custom attribute
ASP.NET (read-only here). A timing middleware, an audit filter, and a [RequireApiKey] attribute.
// A reusable custom middleware CLASS (the convention for non-trivial logic),
// plus an action FILTER and a custom ATTRIBUTE for targeted, declarative work.
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using System.Diagnostics;
// 1) MIDDLEWARE CLASS — runs for EVERY request. The constructor receives
// 'next' (the rest of the pipeline); InvokeAsync is called per request.
public class RequestTimi
...🔎 Deep Dive: middleware or filter?
Middleware sees every request, including static files, health checks, and requests that never reach a controller. It only knows about the raw HttpContext — paths, headers, status codes. Reach for it for truly global concerns: logging, error handling, CORS, compression, authentication.
Filters run inside MVC, after routing has chosen an action, so they know the controller, the action, and the bound model arguments. That makes them perfect for things like model validation or auditing a specific endpoint — work that only makes sense once you know which action is about to run.
Rule of thumb: if it should happen for requests that may never hit a controller, it's middleware. If it depends on the action or its model, it's a filter. In Minimal APIs, the equivalent of an action filter is IEndpointFilter, which supports DI and async cleanly.
Pro Tips
- 💡 Register exception handling first so its
try/catchwraps every later step — a step registered after it can't catch errors that happened before it. - 💡 Always call
await next()in passthrough middleware unless you deliberately mean to short-circuit. Forgetting it silently stalls the request. - 💡 Don't inject scoped services into a middleware constructor — the middleware is a singleton. Accept them as parameters on
InvokeAsyncinstead. - 💡 Use
Map/MapWhento branch (health checks, websockets) so unrelated requests skip the main pipeline entirely. - 💡 Prefer a middleware class over an inline
app.Uselambda once there's real logic — it's testable, reusable, and supports DI.
Common Errors (and the fix)
- Wrong order — auth before routing, or errors not wrapped: a step only protects what comes after it. Put exception handling and logging first, then
UseAuthenticationbeforeUseAuthorization, andMapControllerslast. - Forgetting to call
next(): the request hangs or silently returns an empty 200 — nothing downstream ever runs. Alwaysawait next()in passthrough middleware unless you mean to short-circuit. - Terminal vs passthrough mix-up:
app.Runends the pipeline, so anything registered after it never executes. Useapp.Usewhen you want later steps to keep running. - Exceptions not handled early: if your error-handling middleware is registered after the code that throws, the exception escapes it and the client gets a raw 500. Register it at the very top.
- "Cannot resolve scoped service from root provider": you injected a scoped/DbContext service into a middleware constructor. Take it as an
InvokeAsyncparameter so a per-request instance is provided.
📋 Quick Reference
| Verb / Type | Code | Does |
|---|---|---|
| Passthrough step | app.Use(async (c, next) => ...) | Run, then call next() |
| Terminal step | app.Run(async c => ...) | Ends the pipeline (no next) |
| Branch by path | app.Map("/health", b => ...) | Sub-pipeline for a path |
| Branch by predicate | app.MapWhen(c => ..., b => ...) | Branch on any condition |
| Short-circuit | return; // don't call next | Stop before the handler |
| Custom middleware | app.UseMiddleware<T>() | Register a class with InvokeAsync |
| Action filter | class F : IActionFilter | Run around MVC actions |
| Custom attribute | [RequireApiKey] | Declarative filter logic |
Frequently Asked Questions
Q: What's the difference between app.Use and app.Run?
Use is a passthrough that gets a next delegate and should call it so later steps run. Run is terminal — it has no next and ends the pipeline, so nothing registered after it ever executes.
Q: What happens if I forget to call next()?
The request short-circuits there. Anything registered after that step — including your controllers — never runs. Sometimes that's exactly what you want (a 401 gate); when it isn't, it looks like the request hung or returned nothing.
Q: When should I use a filter instead of middleware?
Use middleware for global concerns that apply to every request (logging, errors, CORS). Use a filter when the logic depends on the chosen MVC action or its model — like validating a specific endpoint's input — because filters run after routing.
Q: Why does the order I register middleware matter so much?
Each step wraps everything registered after it, in and out like nested boxes. So error handling must be first to catch downstream throws, and authentication must come before any step that needs to know who the user is.
Q: Are custom attributes the same as filters?
They're filters in a friendlier wrapper. A class like RequireApiKeyAttribute implements a filter interface (e.g. IAuthorizationFilter) and inherits Attribute, so you can attach it declaratively with [RequireApiKey].
Mini-Challenge: a Logging → Auth → Handler chain
No blanks this time — just a brief and an outline. Using the Step helper provided, build the classic middleware chain in plain C#: a Logging step wrapping an Auth step wrapping the handler, and print the in/out order. Run it and check your output against the expected lines in the comments.
🎯 Mini-Challenge: build the middleware chain
Wire Logging → Auth → Handler yourself and run it.
using System;
class Program
{
// 🎯 MINI-CHALLENGE: build a 3-step middleware chain in plain C#.
//
// Model the classic pipeline: Logging → Auth-check → Handler.
// Use the Step helper below (it wraps a step around the rest of the chain).
//
// 1. Make a terminal 'handler' Action that prints "Handler: 200 OK".
// 2. Wrap it in an "Auth" step, then wrap THAT in a "Logging" step
// (so Logging is the outermost / first step).
// 3. Run the whole chain.
...🎉 Lesson Complete
- ✅ A request flows in through each middleware, hits the handler, then flows out in reverse
- ✅ Order matters — error handling and logging first, auth before anything that needs the user
- ✅
Use= passthrough,Run= terminal,Map/MapWhen= branch the pipeline - ✅ Short-circuiting = not calling
next(), stopping a request early (e.g. a 401 gate) - ✅ A middleware class captures
nextand runsInvokeAsyncper request - ✅ Filters and custom attributes add targeted, declarative cross-cutting logic
- ✅ Next lesson: Entity Framework Core Internals — how EF tracks, translates, and saves your data
Sign up for free to track which lessons you've completed and get learning reminders.