Skip to main content
SEMastery
ASP.NETbeginner

How to Create Custom Middlewares in ASP.NET Core (Step by Step)

Learn to build custom middleware in ASP.NET Core three ways: inline Use, a convention class, and IMiddleware with DI. Beginner friendly, with clear examples.

13 min readUpdated January 9, 2026

A line of security checks at the airport

Think about walking into an airport before a flight. You do not go straight to the plane. You pass through a line of checks, one after another, in a fixed order.

First, a guard checks your ticket at the door. Next, someone checks your ID. Then your bag goes through the scanner. After that you walk through the metal detector. Only then do you reach the gate and board the plane. Each helper does one small job and then sends you to the next helper in line.

And when you land and come back out, you pass through some of the same kinds of checks again, in reverse. Many hands, one fixed order, in and back out.

Middleware in ASP.NET Core works in this exact way. Every web request that reaches your app is like a traveller at the airport. It walks through a line of helpers called middleware. The built-in helpers do common jobs like routing and authentication. But sometimes you need your own check that the airport does not provide. That is custom middleware, and building it is what this guide is about.

What is custom middleware?

Custom middleware is a small piece of code that you write and plug into the request pipeline. It is no different in shape from the built-in middleware. It just does the job you need.

Here is what a custom middleware can do:

  • Look at the request before it goes deeper (read a header, log the path).
  • Decide whether to call the next middleware or stop right there.
  • Do work on the response on the way back (add a header, measure the time taken).

Because each middleware can act both on the way in and on the way out, the pipeline is shaped like nested boxes. Your custom middleware becomes one of those boxes. Here is the simple flow.

A request flows in through built-in middleware and your custom middleware, reaches the endpoint, then the response flows back out in reverse order.

Notice the two most important parts. There is a before part (when the request is going in) and an after part (when the response is coming back). And there is the word next, which means "hand the request to the next helper in line." Every way of writing middleware uses these same two ideas.

The three ways to build it

There are three common ways to create custom middleware in ASP.NET Core. They all reach the same pipeline, but they differ in how much structure and power they give you.

WayWhere you write itBest forCreated
Inline app.UseA lambda in Program.csTiny, quick jobsOnce at startup
Convention-based classA separate class with InvokeAsyncReusable logic, simple needsOnce at startup (singleton)
Factory-based IMiddlewareA class implementing IMiddlewareWhen you need scoped servicesOnce per request

Let us walk through each one, from the simplest to the most powerful. We will keep the airport picture in mind the whole time.

Choosing your middleware style

Tiny job?
Reusable?
Need scoped service?

Steps

1

Tiny job?

Use inline app.Use

2

Reusable?

Write a convention class

3

Need scoped service?

Use IMiddleware + DI

A simple path to decide which of the three styles fits your job.

Way 1: Inline middleware with app.Use

The quickest way is to write the middleware right inside Program.cs. You call app.Use and pass a small function. This function gets two things: the HttpContext (everything about the current request and response) and next (a way to call the next middleware).

Here is a custom middleware that measures how long each request takes and writes it to the console.

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
 
app.Use(async (context, next) =>
{
    // BEFORE: this runs on the way in
    var start = DateTime.UtcNow;
 
    // Hand the request to the next helper in line
    await next(context);
 
    // AFTER: this runs on the way back out
    var ms = (DateTime.UtcNow - start).TotalMilliseconds;
    Console.WriteLine($"{context.Request.Path} took {ms} ms");
});
 
app.MapGet("/", () => "Hello from the endpoint!");
 
app.Run();

Read it slowly. The code before await next(context) runs when the request is going in. Then next passes control deeper into the pipeline. When everything deeper has finished, control comes back, and the code after next runs. That is the in-and-out shape again.

One rule to remember: if you do not call next, the request stops at your middleware. That is sometimes what you want (for example, blocking a request). But if you forget it by mistake, your app will look broken because requests never reach the endpoint.

The before part runs, next passes control deeper, and the after part runs when control returns.

Inline middleware is great for tiny jobs. But when the logic grows, or you want to reuse it across projects, a class is cleaner.

Way 2: Convention-based middleware class

The second way is to move your logic into its own class. ASP.NET Core does not make you implement any interface here. Instead it looks for a class that follows a simple naming convention:

  • A public constructor that takes a RequestDelegate (this is the next).
  • A public method named Invoke or InvokeAsync that takes an HttpContext and returns a Task.

Here is the same timing logic written as a convention-based class.

public class RequestTimingMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger<RequestTimingMiddleware> _logger;
 
    // RequestDelegate is "next". It is given to us once at startup.
    public RequestTimingMiddleware(
        RequestDelegate next,
        ILogger<RequestTimingMiddleware> logger)
    {
        _next = next;
        _logger = logger;
    }
 
    public async Task InvokeAsync(HttpContext context)
    {
        var start = DateTime.UtcNow;
 
        await _next(context); // call the next helper
 
        var ms = (DateTime.UtcNow - start).TotalMilliseconds;
        _logger.LogInformation("{Path} took {Ms} ms",
            context.Request.Path, ms);
    }
}

To plug this into the pipeline, you call UseMiddleware in Program.cs:

app.UseMiddleware<RequestTimingMiddleware>();

Many teams like to wrap that call in a small extension method so it reads nicely. This is a common, friendly pattern.

public static class RequestTimingMiddlewareExtensions
{
    public static IApplicationBuilder UseRequestTiming(
        this IApplicationBuilder app)
    {
        return app.UseMiddleware<RequestTimingMiddleware>();
    }
}
 
// In Program.cs it now reads like plain English:
// app.UseRequestTiming();

There is one important thing to understand about a convention-based class. ASP.NET Core builds it once, when your app starts. The same single object handles every request after that. In other words, it behaves like a singleton.

This is fine for the constructor dependencies above, because ILogger lives for the whole app. But it causes a real problem if you try to inject a scoped service (like a database context) into the constructor. A scoped service is meant to live for one request only. Putting it in a singleton's constructor would freeze it for the whole app, which is wrong and unsafe.

The good news: you can still get scoped services in a convention-based class. You just take them as parameters on InvokeAsync instead of the constructor, because InvokeAsync runs per request.

// Scoped services go on InvokeAsync, not the constructor
public async Task InvokeAsync(HttpContext context, IMyScopedService svc)
{
    await svc.DoWorkAsync();
    await _next(context);
}

Convention-based lifetime

App starts
Object built once
Request arrives
InvokeAsync runs

Steps

1

App starts

Pipeline is set up

2

Object built once

Constructor runs a single time

3

Request arrives

Same object reused

4

InvokeAsync runs

Scoped services injected here

Built once at startup, but scoped services are passed fresh into InvokeAsync each request.

Way 3: Factory-based middleware with IMiddleware

The third way fixes the scoped-service worry in a clean, modern manner. You write a class that implements the IMiddleware interface. ASP.NET Core then uses a factory to build a fresh copy of your middleware for every request.

Because a new object is made per request, you can safely inject scoped services right into the constructor. This is the biggest difference between the two class styles, and it is the main reason to choose IMiddleware.

public class CorrelationIdMiddleware : IMiddleware
{
    private readonly IRequestStore _store; // a scoped service
 
    // Built fresh each request, so scoped DI in the constructor is safe
    public CorrelationIdMiddleware(IRequestStore store)
    {
        _store = store;
    }
 
    public async Task InvokeAsync(HttpContext context, RequestDelegate next)
    {
        // BEFORE: give every request a tracking id
        var id = Guid.NewGuid().ToString("N");
        _store.CorrelationId = id;
        context.Response.Headers["X-Correlation-Id"] = id;
 
        await next(context); // call the next helper
    }
}

Notice that next is now a parameter on InvokeAsync, not a constructor argument. That is part of the IMiddleware shape.

There is one extra step with this style. Because a factory builds it, you must register the middleware in dependency injection before you use it. If you forget this step, the app throws an error at startup telling you the type was not found in the container.

// Register in DI (AddScoped or AddSingleton, your choice)
builder.Services.AddScoped<CorrelationIdMiddleware>();
 
// Then add it to the pipeline like before
app.UseMiddleware<CorrelationIdMiddleware>();

Here is how the factory builds your middleware each time a request comes in.

For IMiddleware, a factory creates a fresh middleware object for each request so scoped services are safe.

Comparing the two class styles

Both class styles look similar at first, so this table makes the real differences clear.

FeatureConvention-basedFactory-based (IMiddleware)
Implements an interfaceNo (naming only)Yes (IMiddleware)
When it is createdOnce at startupOnce per request
Acts likeSingletonPer-request object
Scoped DI in constructorNot safeSafe
Where next livesConstructor (RequestDelegate)InvokeAsync parameter
Must register in DINoYes
Strongly typedLessMore (compiler checks the interface)

A simple rule of thumb: start with a convention-based class for most jobs. Reach for IMiddleware the moment you need a scoped service like a database context or a per-request store inside the middleware itself.

Order is everything

No matter which style you pick, where you place your middleware decides what it can see. Middleware runs in the order you add it in Program.cs: top to bottom on the way in, and reverse on the way back.

Imagine you want your custom middleware to read the logged-in user. The user only exists after UseAuthentication has run. So your middleware must come after it. Put it before, and the user will always be empty.

app.UseRouting();
app.UseAuthentication();   // user is known after this line
app.UseRequestTiming();    // now your MW can read the user
app.UseAuthorization();
app.MapControllers();
app.Run();

A few placement tips that save hours of confusion:

  • Put error-handling middleware very early, so it can catch errors from everything below it.
  • Put middleware that reads the user after UseAuthentication.
  • Put middleware that should run for matched routes after UseRouting.
  • If your middleware should stop some requests, decide carefully whether to call next or not.

A complete, working example

Let us tie it together with a small, complete Program.cs that uses all three styles at once. This is the kind of file you might really see in a beginner project.

var builder = WebApplication.CreateBuilder(args);
 
// Way 3 needs DI registration
builder.Services.AddScoped<IRequestStore, RequestStore>();
builder.Services.AddScoped<CorrelationIdMiddleware>();
 
var app = builder.Build();
 
// Way 1: inline, a quick health note
app.Use(async (context, next) =>
{
    context.Response.Headers["X-Powered-By"] = "Learning .NET";
    await next(context);
});
 
// Way 3: factory-based, gives each request a correlation id
app.UseMiddleware<CorrelationIdMiddleware>();
 
// Way 2: convention-based, times the request
app.UseRequestTiming();
 
app.MapGet("/", () => "All three middlewares ran!");
 
app.Run();

When a request hits /, it passes through the inline header middleware, then the correlation id middleware, then the timing middleware, then the endpoint. On the way back, the timing middleware finishes last (because it started first), writing the elapsed time. That reverse order is the airport coming-home line again.

Common mistakes to avoid

Beginners hit the same small traps. Here are the ones to watch for:

  • Forgetting next. If you never call next, the request never reaches the endpoint. Only skip it on purpose.
  • Writing to the response after it has started. Once the response body begins sending, you cannot change status codes or headers. Do that work before await next.
  • Injecting scoped services into a convention-based constructor. Use InvokeAsync parameters, or switch to IMiddleware.
  • Forgetting to register IMiddleware in DI. The app will fail at startup with a clear message.
  • Wrong order. Place your middleware where it can actually see what it needs (user, route, etc.).

When should you write your own?

You do not need custom middleware for everything. ASP.NET Core already ships middleware for routing, authentication, authorization, CORS, HTTPS redirection, and more. Reach for those first.

Write your own when the job runs for every request and is cross-cutting, meaning it does not belong to one endpoint. Good examples are adding a correlation id, measuring timing, logging request details, or adding a custom security header. If the logic only matters for one endpoint, a filter or the endpoint code itself is usually a better home.

Quick recap

  • Custom middleware is your own helper in the request pipeline, like an extra check at the airport.
  • Every middleware has a before part, a call to next, and an after part.
  • Way 1 is inline app.Use with a lambda, perfect for tiny jobs.
  • Way 2 is a convention-based class with InvokeAsync, built once at startup (singleton-like).
  • Way 3 is a factory-based class that implements IMiddleware, built fresh per request, safe for scoped services, but must be registered in DI.
  • Convention-based classes can still use scoped services by taking them as InvokeAsync parameters.
  • Order matters. Place middleware where it can see the user, route, or errors it needs.
  • Always remember to call next, and do response changes before the response starts.

References and further reading

Related Posts