HttpContext
The request and response objects underneath every handler, and why a reference to HttpContext must not outlive the request.
app.MapGet("/", (HttpContext context) => context.Request.Headers.UserAgent.ToString());Binding, results and middleware are all conveniences built on one object. HttpContext holds everything about a single HTTP request and the response being built for it. One is created when a request arrives and it is finished with when the response completes.
What is on it
Request is an HttpRequest: Method, Path, Headers, Query, RouteValues, Cookies, and Body as a stream. Response is an HttpResponse: StatusCode, ContentType, Headers, Cookies and Body. User is the authenticated identity, Items is a dictionary that lives as long as the request, RequestServices resolves services for it, and RequestAborted is cancelled when the client goes away. Taking HttpContext, HttpRequest, ClaimsPrincipal or CancellationToken as a handler parameter is shorthand for reaching into the same object.
Order matters
Status code and headers must be set before anything is written to the body. The first write flushes, and from then on HasStarted is true and changing a header throws InvalidOperationException: Headers are read-only, response has already started.
Why holding it is a bug
The object is not thread safe, and it is reset once the response completes. A reference captured in a background task or a field therefore reads another request's data or throws. Copy the values you need, as values, while the request is still running, and hand the copies to the work. IHttpContextAccessor has the same rule, plus it returns null outside a request.
Example
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapGet("/whoami", (HttpContext context) =>
{
// Longhand: the indexer takes any header name, and is not case sensitive.
string agent = context.Request.Headers["User-Agent"].ToString();
// Shorthand for that exact header, with no string to misspell.
string sameAgent = context.Request.Headers.UserAgent.ToString();
string? theme = context.Request.Cookies["theme"];
// Both must be set before anything is written to the body.
context.Response.Headers["X-Demo"] = "1";
context.Response.Cookies.Append("theme", theme ?? "light", new CookieOptions
{
HttpOnly = true, // JavaScript cannot read it
Secure = true, // sent over HTTPS only
SameSite = SameSiteMode.Lax // not sent on cross-site POSTs
});
return TypedResults.Ok(new
{
method = context.Request.Method,
path = context.Request.Path.Value,
agent,
bothFormsAgree = agent == sameAgent,
theme
});
});
app.Run();When to use it
- Read a custom header that no binding attribute covers and pass it to a downstream call.
- Set a cookie with HttpOnly and Secure after a successful sign-in.
- Stop a slow database query when the client has already disconnected.
- Attach a correlation id in middleware and read it again in the handler.
More examples
The parameters are shorthand for the context
Prefer the second form. It states exactly what the handler touches, which makes the method easier to test, and it is the same object underneath either way. Take HttpContext itself when you need something with no parameter equivalent, such as Items, Features or writing to the response body directly.
using System.Security.Claims;
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
// Longhand: one parameter, everything read off it.
app.MapGet("/v1/me", async (HttpContext context) =>
{
string? name = context.User.Identity?.Name;
string? agent = context.Request.Headers.UserAgent;
await Task.Delay(50, context.RequestAborted);
return TypedResults.Ok(new { name, agent });
});
// Shorthand: the same three values, bound as parameters. Identical behaviour.
// ClaimsPrincipal comes from context.User, HttpRequest from context.Request,
// and CancellationToken from context.RequestAborted.
app.MapGet("/v2/me", async (
ClaimsPrincipal user,
HttpRequest request,
CancellationToken cancellationToken) =>
{
string? name = user.Identity?.Name;
string? agent = request.Headers.UserAgent;
await Task.Delay(50, cancellationToken);
return TypedResults.Ok(new { name, agent });
});
app.Run();Passing a value down the pipeline with Items
Items is the right place for data that belongs to one request and has no business being a service: a correlation id, a resolved tenant, a parsed header. The key is a string, so keep the keys in one static class rather than retyping the literal. Note that the response header is written in the middleware before next, not after: once the endpoint has written the body, headers are read-only.
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
// Middleware runs for every request, before and after the endpoint.
app.Use(async (context, next) =>
{
string correlationId = context.Request.Headers["X-Correlation-Id"].ToString();
if (string.IsNullOrEmpty(correlationId))
{
correlationId = context.TraceIdentifier;
}
// Items lives exactly as long as this request.
context.Items["CorrelationId"] = correlationId;
// Set before the endpoint writes anything, or this throws.
context.Response.Headers["X-Correlation-Id"] = correlationId;
await next(context);
});
app.MapGet("/orders", (HttpContext context) =>
{
string correlationId = (string)context.Items["CorrelationId"]!;
return TypedResults.Ok(new { correlationId });
});
app.Run();The bug, and the fix
This is the shape of the bug: everything works under one user on a laptop and produces impossible log lines in production. Copying a reference is not enough either, because a header dictionary belongs to the context as much as the context does; copy the strings. Note also that Task.Run is only acceptable for work nobody depends on, since a shutdown drops it; real background work belongs in a queue a hosted service drains.
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapPost("/reports", (HttpContext context) =>
{
// Wrong. The handler returns, the response completes, and the context is
// reset and reused. By the time this runs, Path belongs to someone else's
// request or the read throws.
//
// _ = Task.Run(() => Audit(context.Request.Path.Value!, context.User.Identity?.Name));
// Right. Copy the values out as values while the request is still alive,
// then give the copies to the background work.
string path = context.Request.Path.Value ?? "/";
string? user = context.User.Identity?.Name;
_ = Task.Run(() => Audit(path, user));
return TypedResults.Accepted("/reports/1");
});
app.Run();
// Copies only. Nothing here can reach the request it came from.
static void Audit(string path, string? user)
{
Console.WriteLine($"audited {path} for {user ?? "anonymous"}");
}
Discussion