HttpContext, Request and Response

Read headers, query and body from HttpContext, re-read a buffered body, pass data with Items and Features, and avoid IHttpContextAccessor traps.

Syntaxcontext.Request.EnableBuffering(); var raw = await new StreamReader(context.Request.Body, leaveOpen: true).ReadToEndAsync(); context.Request.Body.Position = 0;

HttpContext is the per-request object every middleware and endpoint shares: Request, Response, User, Items, Features, RequestServices (the request scope), RequestAborted and TraceIdentifier. Kestrel pools and reuses these objects, so a context is only valid until the request ends, and it is not thread-safe.

Request: headers, query, body

Request.Headers is case-insensitive and returns StringValues, which can hold several values; a missing header is StringValues.Empty, not null. Typed shortcuts exist for common headers (Headers.Authorization, Headers.ContentType), and Request.GetTypedHeaders() parses them into objects. Request.Query works the same way for the query string. In endpoints, prefer binding ([FromHeader], [FromQuery]); reach for HttpContext in middleware and infrastructure code.

Request.Body is a forward-only network stream that can be read once. Reading it in middleware leaves nothing for model binding. Request.EnableBuffering() wraps it in a stream that keeps the first 30 KB in memory and spills the rest to a temp file, so you can read, set Position = 0, and let the endpoint read again. Use leaveOpen: true on any StreamReader, or disposing the reader closes the body. Synchronous reads such as ReadToEnd() throw Synchronous operations are disallowed because Kestrel sets AllowSynchronousIO to false; always use the async APIs.

Response: status, headers, started

Status and headers can only change until the first body byte is written. After that Response.HasStarted is true and setting a header throws. Check HasStarted before writing an error response from middleware, and use Response.OnStarting for headers computed late. Response.OnCompleted runs after the response is sent; use it for cleanup, not for work the client must wait for.

Items and Features

Items is a per-request IDictionary<object, object?> for passing data between middleware and endpoints. Use a private static object as the key, not a string, so two libraries cannot collide. Features is the lower-level collection of server capabilities: IHttpMaxRequestBodySizeFeature, IHttpConnectionFeature, IHttpResponseBodyFeature, IHttpRequestTimeoutFeature. A feature can be missing (null) depending on server and middleware.

IHttpContextAccessor caveats

AddHttpContextAccessor() gives singletons access to the current context through an AsyncLocal. It is convenient and it is a trap:

  • Outside a request (background services, start-up) HttpContext is null.
  • Captured into fire-and-forget work, it points at a context that has been recycled for another request: you read another user's headers, or get ObjectDisposedException.
  • It hides a dependency on HTTP inside domain services.

Copy the values you need (user id, tenant, correlation id) into an explicit object or method parameter before leaving the request.

The example verifies a webhook HMAC over the exact body bytes, not a decoded string. Production verification also checks a signed timestamp to reject replayed deliveries.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Verify an HMAC signature over the raw body of a payment webhook, then let the endpoint bind the same JSON.
  • Pass the resolved tenant id from middleware to endpoints through HttpContext.Items with a private key.
  • Fix receipts sent with the wrong customer's language after HttpContext was captured inside Task.Run.

More examples

Pitfall: HttpContext captured by background work

The fix is a plain record with copied values, passed through a bounded channel to a background worker. Nothing in the job refers to <code>HttpContext</code>, so it is valid after the response is sent. The same rule applies to <code>IHttpContextAccessor</code>, which simply returns whatever context is current on that async flow.

Example · csharp
using System.Threading.Channels;

var builder = WebApplication.CreateSlimBuilder(args);
builder.Services.AddSingleton(Channel.CreateBounded<ReceiptJob>(1000));
var app = builder.Build();

app.MapPost("/orders/{id:int}/receipt", async (int id, HttpContext context, Channel<ReceiptJob> queue) =>
{
    // Wrong: _ = Task.Run(() => SendReceipt(context.User.Identity!.Name, context.Request.Headers["Accept-Language"]));
    // The request ends immediately, Kestrel recycles the context, and the background
    // code reads another customer's data or throws ObjectDisposedException.

    // Right: copy plain values while the request is alive and hand them over.
    var job = new ReceiptJob(
        OrderId: id,
        CustomerEmail: context.User.FindFirst("email")?.Value ?? "unknown",
        Culture: context.Request.Headers.AcceptLanguage.ToString(),
        CorrelationId: context.TraceIdentifier);
    await queue.Writer.WriteAsync(job, context.RequestAborted);
    return TypedResults.Accepted($"/orders/{id}/receipt");
});

app.Run();

public record ReceiptJob(int OrderId, string CustomerEmail, string Culture, string CorrelationId);

Pitfall: synchronous reads and a closed body

Kestrel disables synchronous I/O because a blocked thread per upload starves the thread pool under load. Reading line by line with <code>ReadLineAsync</code> and the <code>RequestAborted</code> token streams a CSV import without holding the whole body in memory and stops when the client disconnects. The limit is set on <code>IHttpMaxRequestBodySizeFeature</code> because a chunked upload has no <code>Content-Length</code> to check.

Example · csharp
using Microsoft.AspNetCore.Http.Features;

var builder = WebApplication.CreateSlimBuilder(args);
var app = builder.Build();

app.MapPost("/imports/products", async (HttpRequest request) =>
{
    // Wrong: new StreamReader(request.Body).ReadToEnd()
    //  - throws "Synchronous operations are disallowed" (AllowSynchronousIO is false)
    //  - disposing the reader would also close the request body

    // Right: async read, size enforced on the stream, reader left open.
    // Content-Length is only an early reject: chunked uploads do not send it.
    if (request.ContentLength is > 1_048_576)
    {
        return Results.StatusCode(StatusCodes.Status413PayloadTooLarge);
    }
    // Kestrel now stops any body past 1 MB, chunked or not, with 413.
    request.HttpContext.Features.Get<IHttpMaxRequestBodySizeFeature>()!.MaxRequestBodySize = 1_048_576;
    using var reader = new StreamReader(request.Body, leaveOpen: true);
    var lines = 0;
    while (await reader.ReadLineAsync(request.HttpContext.RequestAborted) is { } line)
    {
        if (line.Length > 4_096)
        {
            return Results.BadRequest(new { error = $"Line {lines + 1} is longer than 4096 characters." });
        }
        if (line.Length > 0) lines++;
    }
    return Results.Ok(new { imported = lines });
});

app.Run();

Typed Items keys and a feature lookup

A private object key means nothing outside this class can read or overwrite the tenant in <code>Items</code>, and the extension methods give it a type. <code>IHttpMaxRequestBodySizeFeature</code> becomes read-only once the body has started being read, so the check on <code>IsReadOnly</code> avoids an exception.

Example · csharp
using Microsoft.AspNetCore.Http.Features;

var builder = WebApplication.CreateSlimBuilder(args);
var app = builder.Build();

app.Use(async (context, next) =>
{
    // From the authenticated user's claim, not a client-supplied header.
    context.SetTenant(context.User.FindFirst("tenant_id")?.Value ?? "unknown");

    // Raise the body limit for one path before anything reads the body.
    if (context.Request.Path.StartsWithSegments("/imports")
        && context.Features.Get<IHttpMaxRequestBodySizeFeature>() is { IsReadOnly: false } limit)
    {
        limit.MaxRequestBodySize = 100 * 1024 * 1024;
    }
    await next(context);
});

app.MapGet("/invoices", (HttpContext context) => TypedResults.Ok(new { tenant = context.GetTenant() }));
app.Run();

public static class TenantHttpContextExtensions
{
    private static readonly object TenantKey = new();

    public static void SetTenant(this HttpContext context, string tenantId) => context.Items[TenantKey] = tenantId;

    public static string GetTenant(this HttpContext context) =>
        context.Items.TryGetValue(TenantKey, out var value) && value is string tenant ? tenant : "unknown";
}

Discussion

  • Be the first to comment on this lesson.