| name | aspnetcore-middleware-headers |
| description | How to safely set response headers in ASP.NET Core middleware |
| domain | aspnetcore, middleware, http |
| confidence | high |
| source | earned (bug fix 2026-02-24) |
Context
When writing custom ASP.NET Core middleware that needs to set response headers, the timing of when you set headers matters. Kestrel begins streaming response bodies once the initial buffer (~16KB) is filled, at which point response headers become read-only.
Patterns
✅ Correct: Use OnStarting callback
Set response headers using the context.Response.OnStarting() callback before calling await next():
app.Use(async (context, next) =>
{
context.Response.OnStarting(() =>
{
if (!context.Response.Headers.ContainsKey("X-Custom-Header"))
{
context.Response.Headers["X-Custom-Header"] = "value";
}
return Task.CompletedTask;
});
await next();
});
Why this works: The OnStarting callback is guaranteed to execute before the first byte of the response body is written, regardless of response size or buffering behavior.
✅ Alternative: Check HasStarted flag
If you must set headers after next(), check Response.HasStarted first:
app.Use(async (context, next) =>
{
await next();
if (!context.Response.HasStarted && !context.Response.Headers.ContainsKey("X-Custom-Header"))
{
context.Response.Headers["X-Custom-Header"] = "value";
}
});
Caveat: This approach means headers won't be present on responses that started streaming early. Use OnStarting if you need guaranteed header presence.
Anti-Patterns
❌ Setting headers after next() without checking
app.Use(async (context, next) =>
{
await next();
context.Response.Headers["X-Custom-Header"] = "value";
});
Why this fails:
- Kestrel starts streaming once the response buffer is full (~16KB)
- Once streaming starts,
Response.HasStarted is true and headers are read-only
- Attempting to set headers throws
InvalidOperationException: Headers are read-only, response has already started
Symptoms:
- Small responses work (fit in buffer)
- Large responses crash (OpenAPI docs, HTML pages, large JSON)
- Intermittent failures depending on response size
Examples
Real-world example from SquadPlaces (2026-02-24):
Before (broken):
app.Use(async (context, next) =>
{
var blocklist = context.RequestServices.GetRequiredService<IpBlocklistService>();
var ip = context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
if (blocklist.IsBlocked(ip))
{
context.Response.StatusCode = StatusCodes.Status403Forbidden;
await context.Response.WriteAsJsonAsync(new { error = "Blocked" });
return;
}
await next();
if (!context.Response.Headers.ContainsKey("X-RateLimit-Limit"))
{
var isWrite = HttpMethods.IsPost(context.Request.Method);
context.Response.Headers["X-RateLimit-Limit"] = isWrite ? "30" : "60";
}
});
After (fixed):
app.Use(async (context, next) =>
{
var blocklist = context.RequestServices.GetRequiredService<IpBlocklistService>();
var ip = context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
if (blocklist.IsBlocked(ip))
{
context.Response.StatusCode = StatusCodes.Status403Forbidden;
await context.Response.WriteAsJsonAsync(new { error = "Blocked" });
return;
}
context.Response.OnStarting(() =>
{
if (!context.Response.Headers.ContainsKey("X-RateLimit-Limit"))
{
var isWrite = HttpMethods.IsPost(context.Request.Method);
context.Response.Headers["X-RateLimit-Limit"] = isWrite ? "30" : "60";
}
return Task.CompletedTask;
});
await next();
});
References