Middleware, Filters & Custom Attributes

Reviewed & published by Brayan K

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.

Part of the free C# course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.

What You'll Learn

💡 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.

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.

// 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");   // before next() = on the way IN
    await next();                                     // hand off to the next step
    Console.WriteLine("1) Logging  ← response out");  // after next()  = on the way OUT
});

app.Use(async (context, next) =>
{
    Console.WriteLine("2) Auth     → checking");
    await next();
    Console.WriteLine("2) Auth     ← done");
});

// app.Run(...) = the TERMINAL step. It has no 'next' — it ends the pipeline
// and produces the response. Nothing after it ever runs.
app.Run(async context =>
{
    Console.WriteLine("3) Handler  → producing response");
    await context.Response.WriteAsync("Hello from the handler!");
});

app.Run();   // start the web server

// ✅ Expected console output for ONE request:
//    1) Logging  → request in
//    2) Auth     → checking
//    3) Handler  → producing response
//    2) Auth     ← done
//    1) Logging  ← response out
// Notice the OUT order is the reverse of the IN order — it nests like
// Russian dolls. That is why ordering matters so much.

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.

// 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.WriteAsync("Forbidden");   // terminate here
        return;                                           // ← no next(): stop!
    }
    await next();   // every other path continues down the pipeline
});

// Map BRANCHES the pipeline by path. Requests to /health get their own
// mini-pipeline ending in a Run, and never touch the main handler.
app.Map("/health", branch =>
{
    branch.Run(async context =>
        await context.Response.WriteAsync("Healthy"));   // GET /health → "Healthy"
});

// MapWhen / UseWhen branch on any predicate, not just the path.
app.MapWhen(c => c.Request.Query.ContainsKey("debug"), branch =>
{
    branch.Run(async context =>
        await context.Response.WriteAsync("Debug mode"));  // ?debug=1 → "Debug mode"
});

app.Run(async context =>
    await context.Response.WriteAsync("Main handler"));    // everything else

app.Run();

// ✅ Expected responses:
//    GET /health        → "Healthy"      (handled by the Map branch)
//    GET /blocked/x     → 403 "Forbidden" (short-circuited, never reached Run)
//    GET /page?debug=1  → "Debug mode"    (MapWhen branch)
//    GET /page          → "Main handler"  (fell through to the terminal Run)

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.

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");
    };

    static void Main()
    {
        // 🎯 YOUR TURN — build a 2-step pipeline, then run it.

        // The terminal handler — like app.Run, it has no 'next' to call.
        Action handler = () => Console.WriteLine("Handler → response");

        // 1) Wrap the handler in an "Auth" step (Auth runs, then the handler).
        Action auth = Step("Auth", ___);        // 👉 pass  handler  as next

        // 2) Wrap THAT in a "Logging" step so Logging runs first of all.
        Action pipeline = Step("Logging", ___); // 👉 pass  auth  as next

        // 3) Kick off the whole chain.
        ___;                                     // 👉 call  pipeline()

        // ✅ Expected output:
        //    Logging → in
        //    Auth → in
        //    Handler → response
        //    Auth ← out
        //    Logging ← 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.

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 the request has no API key

        Action handler = () => Console.WriteLine("Handler → response");

        // The gate either calls next() (allow) or stops early (block).
        Action gate = () =>
        {
            Console.WriteLine("Gate → checking");
            if (!hasApiKey)
            {
                Console.WriteLine("Gate ✗ blocked (401)");
                ___;                  // 👉 stop here — write  return;  (do NOT call next)
            }
            handler();                // only runs when the key is present
        };

        Action pipeline = Step("Logging", gate);
        pipeline();

        // ✅ Expected output (hasApiKey is false):
        //    Logging → in
        //    Gate → checking
        //    Gate ✗ blocked (401)
        //    Logging ← out
        // The Handler line never prints — the gate short-circuited it.
    }
}

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.

// 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 RequestTimingMiddleware
{
    private readonly RequestDelegate _next;
    public RequestTimingMiddleware(RequestDelegate next) => _next = next;

    public async Task InvokeAsync(HttpContext context)
    {
        var sw = Stopwatch.StartNew();
        await _next(context);                 // let the rest of the pipeline run
        sw.Stop();
        Console.WriteLine(
            $"{context.Request.Path} took {sw.ElapsedMilliseconds}ms");  // e.g. /orders took 4ms
    }
}

// 2) ACTION FILTER — runs only around MVC controller actions, not every
//    request. Ideal for cross-cutting concerns scoped to controllers.
public class AuditFilter : IActionFilter
{
    public void OnActionExecuting(ActionExecutingContext c) =>
        Console.WriteLine($"→ before {c.ActionDescriptor.DisplayName}");
    public void OnActionExecuted(ActionExecutedContext c) =>
        Console.WriteLine($"← after  (failed={c.Exception != null})");
}

// 3) CUSTOM ATTRIBUTE — filter logic you attach declaratively with [RequireApiKey].
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
public class RequireApiKeyAttribute : Attribute, IAuthorizationFilter
{
    public void OnAuthorization(AuthorizationFilterContext context)
    {
        if (!context.HttpContext.Request.Headers.ContainsKey("X-Api-Key"))
            context.Result = new UnauthorizedResult();   // short-circuits to 401
    }
}

[ApiController]
[Route("api/[controller]")]
[RequireApiKey]                      // every action here needs the header
public class OrdersController : ControllerBase
{
    [HttpGet]
    [ServiceFilter(typeof(AuditFilter))]   // audit just this action
    public IActionResult Get() => Ok(new { id = 1, status = "shipped" });
}

// Wiring in Program.cs:
//   app.UseMiddleware<RequestTimingMiddleware>();   // register the class
//   app.MapControllers();

// ✅ Expected for an authorised GET /api/orders:
//    → before OrdersController.Get
//    ← after  (failed=False)
//    /api/orders took 4ms
// ✅ With NO X-Api-Key header: 401 Unauthorized, the action never runs.

🔎 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

Common Errors (and the fix)

📋 Quick Reference

Verb / TypeCodeDoes
Passthrough stepapp.Use(async (c, next) => ...)Run, then call next()
Terminal stepapp.Run(async c => ...)Ends the pipeline (no next)
Branch by pathapp.Map("/health", b => ...)Sub-pipeline for a path
Branch by predicateapp.MapWhen(c => ..., b => ...)Branch on any condition
Short-circuitreturn; // don't call nextStop before the handler
Custom middlewareapp.UseMiddleware<T>()Register a class with InvokeAsync
Action filterclass F : IActionFilterRun 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.

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.
    //
    // ✅ Expected output:
    //    Logging → in
    //    Auth → in
    //    Handler: 200 OK
    //    Auth ← out
    //    Logging ← out

    static Action Step(string name, Action next) => () =>
    {
        Console.WriteLine($"{name} → in");
        next();
        Console.WriteLine($"{name} ← out");
    };

    static void Main()
    {
        // your code here
    }
}

🎉 Lesson Complete

Practice quiz

In what order does a request flow through the middleware pipeline relative to the response?

  • In and out in the same order
  • Randomly
  • In through each step, then out in reverse order
  • Only inward

Answer: In through each step, then out in reverse order. A request flows down through each middleware to the handler, then back up in reverse — nesting like Russian dolls.

In passthrough middleware, when does code before 'await next()' run?

  • On the way in (request)
  • On the way out (response)
  • Never
  • Only on errors

Answer: On the way in (request). Code before await next() runs on the way in; code after it runs on the way out.

What is the difference between app.Use and app.Run?

  • Use is terminal; Run is passthrough
  • They are identical
  • Run runs first always
  • Use is a passthrough that gets next(); Run is terminal with no next

Answer: Use is a passthrough that gets next(); Run is terminal with no next. Use is a passthrough that receives and should call next(); Run is terminal and ends the pipeline.

What does 'short-circuiting' the pipeline mean?

  • Calling next() twice
  • Not calling next(), so the request stops before the handler
  • Throwing an exception
  • Reordering middleware

Answer: Not calling next(), so the request stops before the handler. By not calling next() (e.g. returning a 403), a step stops the request before it reaches the handler.

Why must exception-handling middleware be registered first?

  • So its try/catch wraps every later step and can catch downstream throws
  • It runs fastest
  • It needs the user identity
  • Order doesn't matter

Answer: So its try/catch wraps every later step and can catch downstream throws. A step only protects what comes after it, so error handling must be first to wrap and catch downstream exceptions.

What do app.Map and app.MapWhen do?

  • End the pipeline
  • Add authentication
  • Branch the pipeline based on the path or a predicate
  • Log requests

Answer: Branch the pipeline based on the path or a predicate. Map branches by path; MapWhen/UseWhen branch on any predicate, giving requests their own sub-pipeline.

What happens to middleware registered after app.Run?

  • It runs first
  • It never executes — Run is terminal
  • It runs in parallel
  • It catches exceptions

Answer: It never executes — Run is terminal. app.Run ends the pipeline, so anything registered after it never runs.

How does a custom middleware class receive the rest of the pipeline?

  • As a static field
  • It doesn't
  • Via a global variable
  • Its constructor captures next (a RequestDelegate); InvokeAsync runs per request

Answer: Its constructor captures next (a RequestDelegate); InvokeAsync runs per request. A middleware class takes next (RequestDelegate) in its constructor and implements InvokeAsync(HttpContext) per request.

When should you use a filter instead of middleware?

  • For static files
  • When the logic depends on the chosen MVC action or its model
  • For every request including health checks
  • Never

Answer: When the logic depends on the chosen MVC action or its model. Filters run after routing, inside MVC, so use them when the logic needs the action or its bound model; use middleware for global concerns.

Why should you not inject a scoped service into a middleware constructor?

  • It's faster as a field
  • Scoped services don't exist
  • The middleware is a singleton; accept scoped services as InvokeAsync parameters instead
  • It causes infinite loops

Answer: The middleware is a singleton; accept scoped services as InvokeAsync parameters instead. Middleware is constructed once (singleton), so take per-request scoped services as InvokeAsync parameters to get a per-request instance.

Continue this course