Write middleware three ways, inject scoped services correctly, read and buffer request and response bodies safely, and package it as a clean extension method.

Bishal Biswas
Self Learner
Three ways to write it
There is inline middleware, convention based middleware and factory based middleware. Each suits a different size of job.
Inline, for something small
app.Use(async (context, next) =>
{
context.Response.Headers["X-Powered-By"] = "BookStore API";
await next();
});
Perfect for two or three lines. Beyond that, Program.cs gets crowded.
Convention based, the usual choice
A plain class following a naming convention. There is no interface to implement, which looks like magic until you see the two rules.
public class RequestLoggingMiddleware
{
private readonly RequestDelegate next;
private readonly ILogger<RequestLoggingMiddleware> logger;
// Rule 1: the constructor takes RequestDelegate first
public RequestLoggingMiddleware(RequestDelegate next, ILogger<RequestLoggingMiddleware> logger)
{
this.next = next;
this.logger = logger;
}
// Rule 2: a method called InvokeAsync taking HttpContext, returning Task
public async Task InvokeAsync(HttpContext context)
{
logger.LogInformation("--> {Method} {Path}", context.Request.Method, context.Request.Path);
await next(context);
logger.LogInformation("<-- {Status}", context.Response.StatusCode);
}
}
Register it:
app.UseMiddleware<RequestLoggingMiddleware>();
The lifetime detail that catches everyone: middleware is created once, at startup. It is effectively a singleton. So the constructor can only take singletons.
Scoped services go in InvokeAsync, where the container injects them per request:
public async Task InvokeAsync(HttpContext context, AppDbContext db, IAuditService audit)
{
// db and audit are resolved fresh for THIS request
await audit.LogAsync(context.Request.Path);
await next(context);
}
Put a DbContext in the constructor and you get one context shared by every request for the life of the app. That is the captive dependency bug, and here it is not caught at startup.
Factory based, when you want an interface
IMiddleware gives you a real interface and a per request lifetime:
public class ApiKeyMiddleware : IMiddleware
{
private readonly IApiKeyValidator validator; // scoped is fine here
public ApiKeyMiddleware(IApiKeyValidator validator) => this.validator = validator;
public async Task InvokeAsync(HttpContext context, RequestDelegate next)
{
string? key = context.Request.Headers["X-Api-Key"];
if (key is null || !await validator.IsValidAsync(key))
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
await context.Response.WriteAsJsonAsync(new { error = "Invalid API key." });
return;
}
await next(context);
}
}
This one must be registered in DI as well as in the pipeline:
builder.Services.AddScoped<ApiKeyMiddleware>();
app.UseMiddleware<ApiKeyMiddleware>();
Register it scoped and the constructor can take scoped services safely, because a new instance is created per request. The tradeoff is a small allocation per request, which is almost never measurable.
Use factory based middleware when you want the interface for testing or when constructor injection of scoped services makes the code clearer.
Package it as an extension method
Callers should not have to remember the class name:
public static class MiddlewareExtensions
{
public static IApplicationBuilder UseRequestLogging(this IApplicationBuilder app)
=> app.UseMiddleware<RequestLoggingMiddleware>();
public static IApplicationBuilder UseApiKeyAuth(this IApplicationBuilder app)
=> app.UseMiddleware<ApiKeyMiddleware>();
}
app.UseRequestLogging();
app.UseApiKeyAuth();
Now it reads like the built in middleware, and you can add options later without changing every call site.
Middleware with options
Pass configuration through the UseMiddleware call:
public class RequestTimingOptions
{
public int SlowRequestThresholdMs { get; set; } = 500;
}
public class RequestTimingMiddleware
{
private readonly RequestDelegate next;
private readonly RequestTimingOptions options;
private readonly ILogger<RequestTimingMiddleware> logger;
public RequestTimingMiddleware(
RequestDelegate next,
IOptions<RequestTimingOptions> options,
ILogger<RequestTimingMiddleware> logger)
{
this.next = next;
this.options = options.Value;
this.logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
var watch = Stopwatch.StartNew();
await next(context);
watch.Stop();
if (watch.ElapsedMilliseconds > options.SlowRequestThresholdMs)
logger.LogWarning("Slow request: {Path} took {Ms}ms",
context.Request.Path, watch.ElapsedMilliseconds);
}
}
public static IApplicationBuilder UseRequestTiming(
this IApplicationBuilder app, Action<RequestTimingOptions>? configure = null)
{
if (configure is not null)
app.ApplicationServices.GetRequiredService<IOptions<RequestTimingOptions>>();
return app.UseMiddleware<RequestTimingMiddleware>();
}
builder.Services.Configure<RequestTimingOptions>(o => o.SlowRequestThresholdMs = 200);
app.UseRequestTiming();
Reading the request body
The request body is a forward only stream. Read it once and it is gone, so model binding downstream gets nothing.
To read it and still let the endpoint work, enable buffering and rewind:
public async Task InvokeAsync(HttpContext context)
{
context.Request.EnableBuffering(); // allows the stream to be re read
using var reader = new StreamReader(
context.Request.Body,
Encoding.UTF8,
detectEncodingFromByteOrderMarks: false,
leaveOpen: true); // do NOT close the stream
string body = await reader.ReadToEndAsync();
context.Request.Body.Position = 0; // rewind so model binding can read it
logger.LogDebug("Body: {Body}", body);
await next(context);
}
Three details, all of which cause bugs if missed. EnableBuffering must be called before reading. leaveOpen: true stops the reader disposing the stream. And the position must be reset to zero.
Two warnings. This buffers the whole body in memory, so guard it by size and content type. And request bodies contain passwords and card numbers, so never log them unfiltered.
Reading the response body
Harder, because the response stream writes straight to the network. You have to swap in your own stream:
public async Task InvokeAsync(HttpContext context)
{
Stream original = context.Response.Body;
using var buffer = new MemoryStream();
context.Response.Body = buffer;
try
{
await next(context);
buffer.Position = 0;
string responseText = await new StreamReader(buffer).ReadToEndAsync();
logger.LogDebug("Response: {Body}", responseText);
buffer.Position = 0;
await buffer.CopyToAsync(original); // send it on to the real stream
}
finally
{
context.Response.Body = original; // always restore it
}
}
This defeats streaming and buffers whole responses in memory. Use it for debugging in development, not in production.
Always call next, or answer
Every path through your middleware must either call next or produce a response. Miss one and the request hangs until it times out:
public async Task InvokeAsync(HttpContext context)
{
if (context.Request.Path.StartsWithSegments("/health"))
{
// Bug: neither answers nor continues. The request hangs.
return;
}
await next(context);
}
Testing middleware
Middleware is testable without a server, using DefaultHttpContext:
[Fact]
public async Task Missing_api_key_returns_401()
{
var context = new DefaultHttpContext();
context.Response.Body = new MemoryStream();
bool nextCalled = false;
RequestDelegate next = _ => { nextCalled = true; return Task.CompletedTask; };
var middleware = new ApiKeyMiddleware(new AlwaysInvalidValidator());
await middleware.InvokeAsync(context, next);
Assert.Equal(401, context.Response.StatusCode);
Assert.False(nextCalled);
}
Fast, and it proves the short circuit actually short circuits.
Middleware or filter?
Both wrap a request. The dividing line is simple.
Middleware runs for every request, sees the raw HttpContext, and works before model binding. Use it for application wide concerns: logging, security headers, exception handling, API keys.
Filters run after routing and model binding, so they see the bound model and can replace the result. Use them for endpoint specific concerns: validating a request object, checking ownership of a resource.
If you need the typed request model, it has to be a filter. Middleware runs too early.
Quick recap
- Inline
app.Usefor two or three lines. A class for anything larger. - Convention based middleware needs a
RequestDelegateconstructor parameter and anInvokeAsync(HttpContext)method. - It is created once, so the constructor takes singletons only. Scoped services go in
InvokeAsync. IMiddlewaregives you an interface and a per request instance, at the cost of a DI registration.- Wrap registration in an extension method so it reads like built in middleware.
- To read the request body, call
EnableBuffering, useleaveOpen: true, and resetPositionto zero. - Reading the response body means swapping the stream and restoring it in a
finally. - Every path must call
nextor write a response, or the request hangs. - Test with
DefaultHttpContextand a fakeRequestDelegate.
// Three styles of middleware, with the lifetime and stream traps handled.
using System.Diagnostics;
using System.Text;
using Microsoft.Extensions.Options;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddScoped<IApiKeyValidator, ApiKeyValidator>();
builder.Services.AddScoped<ApiKeyMiddleware>(); // IMiddleware must be registered
builder.Services.Configure<RequestTimingOptions>(o => o.SlowRequestThresholdMs = 200);
var app = builder.Build();
app.UseRequestTiming(); // convention based, with options
app.UseApiKeyAuth(); // factory based, can inject scoped services in its constructor
if (app.Environment.IsDevelopment())
app.UseBodyLogging(); // buffers bodies in memory: development only
app.MapGet("/api/books", () => new[] { "Clean Code" });
app.Run();
// ---------- 1. Convention based. Created ONCE, so the constructor is singleton only.
public class RequestTimingMiddleware
{
private readonly RequestDelegate next; // required, and first
private readonly RequestTimingOptions options;
private readonly ILogger<RequestTimingMiddleware> logger; // singleton: safe here
public RequestTimingMiddleware(RequestDelegate next,
IOptions<RequestTimingOptions> options,
ILogger<RequestTimingMiddleware> logger)
=> (this.next, this.options, this.logger) = (next, options.Value, logger);
// Scoped services belong HERE, injected per request. A DbContext in the
// constructor above would be shared by every request for the life of the app.
public async Task InvokeAsync(HttpContext context, IApiKeyValidator perRequestService)
{
var watch = Stopwatch.StartNew();
await next(context); // every path must call next or answer
watch.Stop();
if (watch.ElapsedMilliseconds > options.SlowRequestThresholdMs)
logger.LogWarning("Slow request: {Method} {Path} took {Ms}ms",
context.Request.Method, context.Request.Path, watch.ElapsedMilliseconds);
}
}
public class RequestTimingOptions { public int SlowRequestThresholdMs { get; set; } = 500; }
// ---------- 2. Factory based. A real interface, one instance per request.
public class ApiKeyMiddleware : IMiddleware
{
private readonly IApiKeyValidator validator; // scoped, and safe in the constructor
private readonly ILogger<ApiKeyMiddleware> logger;
public ApiKeyMiddleware(IApiKeyValidator validator, ILogger<ApiKeyMiddleware> logger)
=> (this.validator, this.logger) = (validator, logger);
public async Task InvokeAsync(HttpContext context, RequestDelegate next)
{
if (!context.Request.Path.StartsWithSegments("/api"))
{
await next(context); // not our concern, pass it on
return;
}
string? key = context.Request.Headers["X-Api-Key"];
if (key is null || !await validator.IsValidAsync(key))
{
logger.LogWarning("Rejected request to {Path}: bad API key", context.Request.Path);
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
await context.Response.WriteAsJsonAsync(new { error = "A valid X-Api-Key is required." });
return; // short circuit, and it DID answer
}
await next(context);
}
}
// ---------- 3. Body logging: the stream handling that people get wrong
public class BodyLoggingMiddleware
{
private const int MaxBodyBytes = 4096;
private readonly RequestDelegate next;
private readonly ILogger<BodyLoggingMiddleware> logger;
public BodyLoggingMiddleware(RequestDelegate next, ILogger<BodyLoggingMiddleware> logger)
=> (this.next, this.logger) = (next, logger);
public async Task InvokeAsync(HttpContext context)
{
// --- request body: read it, then rewind so model binding still works
if (context.Request.ContentLength is > 0 and <= MaxBodyBytes &&
context.Request.ContentType?.Contains("json") == true)
{
context.Request.EnableBuffering(); // allows re reading
using var reader = new StreamReader(context.Request.Body, Encoding.UTF8,
detectEncodingFromByteOrderMarks: false, leaveOpen: true); // do not close it
string body = await reader.ReadToEndAsync();
context.Request.Body.Position = 0; // rewind, or binding gets nothing
// Real bodies carry passwords and card numbers. Redact before logging.
logger.LogDebug("Request body: {Body}", Redact(body));
}
// --- response body: swap the stream, copy it on, always restore
Stream original = context.Response.Body;
using var buffer = new MemoryStream();
context.Response.Body = buffer;
try
{
await next(context);
buffer.Position = 0;
string responseText = await new StreamReader(buffer).ReadToEndAsync();
logger.LogDebug("Response {Status}: {Body}", context.Response.StatusCode,
responseText.Length > MaxBodyBytes ? responseText[..MaxBodyBytes] : responseText);
buffer.Position = 0;
await buffer.CopyToAsync(original); // send it to the real network stream
}
finally
{
context.Response.Body = original; // restore even if it threw
}
}
private static string Redact(string json) => System.Text.RegularExpressions.Regex.Replace(
json, "\"(password|token|cardNumber)\"\\s*:\\s*\"[^\"]*\"", "\"$1\":\"***\"",
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
}
// ---------- Extension methods so it reads like built in middleware
public static class MiddlewareExtensions
{
public static IApplicationBuilder UseRequestTiming(this IApplicationBuilder app) =>
app.UseMiddleware<RequestTimingMiddleware>();
public static IApplicationBuilder UseApiKeyAuth(this IApplicationBuilder app) =>
app.UseMiddleware<ApiKeyMiddleware>();
public static IApplicationBuilder UseBodyLogging(this IApplicationBuilder app) =>
app.UseMiddleware<BodyLoggingMiddleware>();
}
public interface IApiKeyValidator { Task<bool> IsValidAsync(string key); }
public class ApiKeyValidator : IApiKeyValidator
{
public Task<bool> IsValidAsync(string key) => Task.FromResult(key == "dev-key-123");
}
Was this explanation helpful?
Your feedback helps us improve the content for everyone.
Thank you for your feedback!
We appreciate you helping us improve.
What could we improve? (Optional)
Read aloud
Ready