Mert Özen Further With Every Line
Backend .NET Core Web API — #38

Working with HttpContext, HttpRequest and HttpResponse: Headers, Cookies, Query, Body, Items and Features

Mert Özen Nov 15, 2026 12 min 12 views
Working with HttpContext, HttpRequest and HttpResponse: Headers, Cookies, Query, Body, Items and Features

In ASP.NET Core, HttpContext is the whole request: the request's headers, cookies, query and body, the response's status code, headers and cookies, Items, Features and RequestAborted. With real outputs we read the body twice, hit the error of adding a header after the response has started, get the right client IP behind a proxy and look at the risks of IHttpContextAccessor.

Version note: The code in this post was built and run with .NET 10 (SDK 10.0.404).

Guess First

We are adding a small middleware to the API of Bookstore, the online bookstore used as the shared example across this site's series, that writes how many milliseconds each request took into the response. The logic is simple: run the endpoint, measure the time, add the header:

app.Use(async (context, next) =>
{
    long start = Stopwatch.GetTimestamp();
    await next(context);
    // The endpoint is done, now write the elapsed time
    context.Response.Headers["X-Response-Time-Ms"] = Stopwatch.GetElapsedTime(start).TotalMilliseconds.ToString("F1");
});

GET /api/debug/request is an ordinary minimal API endpoint that returns JSON. If you send it a request with curl, what do you get? A 500, a 200 without the header, or a 200 with the header?

The version posts ended with post 37 on .NET 11. In the six posts starting here we look at the API surface you touch every day but which the core posts of this series only brushed past: HttpContext, minimal API binding, route groups and endpoint filters, JSON and formatters, built-in middleware and localization. The code uses .NET 10 and minimal APIs, the template default since .NET 8. I give the controller equivalent briefly in every post. The code for these six posts grows as a single project in the repo under samples/daily-api/Bookstore.Api; dotnet run starts it on http://localhost:5238.

HttpContext: The Whole Request in One Object

When Kestrel reads an HTTP request, it turns it into an HttpContext object and hands that object to every middleware in the pipeline and finally to the endpoint. The request itself lives in Request, the response you will write in Response. The authenticated user is in User, the token that is cancelled when the client drops the connection is in RequestAborted, the request ID you can match against logs is in TraceIdentifier, small pieces of data that travel with the request are in Items, and the low-level capabilities offered by the server are in Features. RequestServices, which resolves scoped services, is here too.

In a minimal API you get these objects just by declaring a parameter; HttpContext, HttpRequest, HttpResponse, CancellationToken and ClaimsPrincipal are special types that need no attribute. In a controller the same objects come from the HttpContext, Request, Response and User properties of ControllerBase. So everything in this post applies to controllers as well; only the way you reach the objects changes.

Reading the HttpRequest

Let's write a debug endpoint to look at the parts of a request:

app.MapGet("/api/debug/request", (HttpRequest request) =>
{
    RequestHeaders headers = request.GetTypedHeaders();
    return new
    {
        request.Method,
        request.Scheme,
        Host = request.Host.Value,
        PathBase = request.PathBase.Value,
        Path = request.Path.Value,
        QueryString = request.QueryString.Value,
        Search = request.Query["q"].ToString(),
        UserAgent = request.Headers.UserAgent.ToString(),
        AcceptLanguage = headers.AcceptLanguage.Select(l => $"{l.Value}:{l.Quality ?? 1}"),
        RemoteIp = request.HttpContext.Connection.RemoteIpAddress?.ToString(),
        TraceId = request.HttpContext.TraceIdentifier,
    };
});
$ curl -i "localhost:5238/api/debug/request?q=dune&page=2" -H "Accept-Language: tr-TR,tr;q=0.9,en;q=0.8" -H "User-Agent: bookstore-web/1.0"
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
X-Response-Time-Ms: 2.7

{"method":"GET","scheme":"http","host":"localhost:5238","pathBase":"","path":"/api/debug/request","queryString":"?q=dune&page=2","search":"dune","userAgent":"bookstore-web/1.0","acceptLanguage":["tr-TR:1","tr:0.9","en:0.8"],"remoteIp":"127.0.0.1","traceId":"0HNQ2IPUMTQPI:00000001"}

Path and PathBase are kept apart: if the app is published under /bookstore behind a reverse proxy and you call UsePathBase("/bookstore"), PathBase carries that prefix and Path the rest. Forget to combine them when you build links and you produce broken URLs behind the proxy. Query["q"] returns a StringValues, because the same key can appear more than once in a query string; a missing value gives you an empty StringValues, not an exception.

You can read headers in two ways. request.Headers["User-Agent"] works with a string key; properties such as request.Headers.UserAgent do the same job without the risk of a typo. When you need to parse the value, GetTypedHeaders() helps: above we read Accept-Language together with its quality values without splitting it by hand. Cookies are in request.Cookies, form fields in request.Form. Check HasFormContentType before touching Form; reading Form on a JSON request throws an exception. In async code prefer ReadFormAsync().

Reading the Body Twice

For institutional orders (bulk orders from schools and libraries) we wanted to log the incoming body for auditing. The first attempt looked like this:

app.Use(async (context, next) =>
{
    if (HttpMethods.IsPost(context.Request.Method) && context.Request.Path.StartsWithSegments("/api/institution/orders"))
    {
        using StreamReader reader = new(context.Request.Body, leaveOpen: true);
        string body = await reader.ReadToEndAsync(context.RequestAborted);
        app.Logger.LogInformation("Institutional order body ({Length} chars): {Body}", body.Length, body);
    }
    await next(context);
});

The log line was correct, but the endpoint returned 400 (in Development, output shortened):

$ curl -i -X POST localhost:5238/api/institution/orders -H "X-Tenant-Id: 101" -H "Content-Type: application/json" -d '{"items":[{"isbn":"978-0547928227","quantity":25}]}'
HTTP/1.1 400 Bad Request
Content-Type: text/plain; charset=utf-8

Microsoft.AspNetCore.Http.BadHttpRequestException: Failed to read parameter "InstitutionOrderRequest order" from the request body as JSON.
 ---> System.Text.Json.JsonException: The input does not contain any JSON tokens. ...

The request body is a stream, and by default it is read once, forward only. The middleware read it to the end, so the endpoint's JSON binding found an empty stream. The fix is to call EnableBuffering() before reading and rewind afterwards:

public class RequestBodyLoggingMiddleware(RequestDelegate next, ILogger<RequestBodyLoggingMiddleware> logger)
{
    public async Task InvokeAsync(HttpContext context)
    {
        if (HttpMethods.IsPost(context.Request.Method) && context.Request.Path.StartsWithSegments("/api/institution/orders"))
        {
            // Keeps the body in memory/on disk so it can be read again
            context.Request.EnableBuffering();

            using StreamReader reader = new(context.Request.Body, leaveOpen: true);
            string body = await reader.ReadToEndAsync(context.RequestAborted);
            logger.LogInformation("Institutional order body ({Length} chars): {Body}", body.Length, body);

            // Let the next reader start from the beginning
            context.Request.Body.Position = 0;
        }

        await next(context);
    }
}

EnableBuffering keeps the first 30 KB of the body in memory and the rest in a temporary file. That's why you should turn it on only for the paths that need it; buffering every request body costs memory and disk. leaveOpen: true matters as well: the StreamReader must not close the request stream when it is disposed. If all you want from the body is a DTO, you don't need to deal with the stream at all; await request.ReadFromJsonAsync<InstitutionOrderRequest>() deserializes it with the same JSON settings. There is also a ready-made way to log requests and responses: AddHttpLogging, covered in the HTTP logging post of the Logging in .NET series, which also goes into what logging the body costs.

The Client's IP Address and Proxies

Connection.RemoteIpAddress is the other end of the TCP connection. If your app sits behind Nginx, YARP or a load balancer, that address belongs to the proxy, not the client; the real address arrives in the X-Forwarded-For header. The ForwardedHeaders middleware reads that header and fixes RemoteIpAddress and Scheme, but only accepts headers from proxies it trusts:

builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
    options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
    // Loopback is trusted by default; add the address of the real proxy
    options.KnownProxies.Add(IPAddress.Parse("10.0.0.5"));
});

var app = builder.Build();

app.UseForwardedHeaders();
$ curl localhost:5238/api/debug/request -H "X-Forwarded-For: 203.0.113.7" -H "X-Forwarded-Proto: https"
{"method":"GET","scheme":"https",...,"remoteIp":"203.0.113.7",...}

The request came from localhost and loopback addresses are on the trusted list by default, so the middleware believed the header. That's handy when you test on your own machine, but it also shows the risk: loosen the trust list on an app that runs without a proxy and anyone can write any IP they like. If you tied rate limiting or the audit log to the IP, that is a serious hole. UseForwardedHeaders belongs at the very start of the pipeline so every later middleware sees the corrected address. The KnownIPNetworks change in .NET 10 and container setups are covered in the reverse proxy post of the Web Servers and Hosting series.

Writing to the HttpResponse

On the response side you need to tell three things apart: the status code and headers, cookies, and the body. Let's start with a cookie that carries the cart ID:

const string CartCookie = "bookstore_cart";

app.MapPost("/api/cart/items", (CartItem item, HttpContext context, CartStore carts) =>
{
    if (!Guid.TryParse(context.Request.Cookies[CartCookie], out Guid cartId))
    {
        cartId = Guid.NewGuid();
        context.Response.Cookies.Append(CartCookie, cartId.ToString(), new CookieOptions
        {
            HttpOnly = true,
            Secure = true,
            SameSite = SameSiteMode.Lax,
            MaxAge = TimeSpan.FromDays(7),
            IsEssential = true,
        });
    }

    return carts.Add(cartId, item);
});
$ curl -i -X POST localhost:5238/api/cart/items -H "Content-Type: application/json" -d '{"isbn":"978-0441013593","quantity":1}'
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Set-Cookie: bookstore_cart=072ee971-bf4b-4617-8645-d6a0b3f8cd71; max-age=604800; path=/; secure; samesite=lax; httponly
X-Response-Time-Ms: 5.2

[{"isbn":"978-0441013593","quantity":1}]

HttpOnly hides the cookie from JavaScript, so an XSS payload can't steal it. Secure makes sure it is sent only over HTTPS; browsers and curl treat localhost as secure, which is why it also worked over plain HTTP locally. SameSite=Lax keeps the cookie off POST requests that come from other sites. When I saved the cookie with -c and sent the second request with -b, the second book went into the same cart and GET /api/cart returned both lines. To remove it, Response.Cookies.Delete(CartCookie) writes an expired Set-Cookie.

If you have to write the body by hand, there's WriteAsJsonAsync; we'll use it in the tenant middleware shortly. In a minimal API, though, returning TypedResults is usually the better choice because the result also shows up in the OpenAPI document; we'll see that in post 40.

The Answer: After the Response Has Started

When I ran the middleware from the guess, curl saw this:

HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Transfer-Encoding: chunked

{"method":"GET","scheme":"http",...}

Status 200, full body, but no X-Response-Time-Ms. The server log had this:

fail: Microsoft.AspNetCore.Server.Kestrel[13]
      Connection id "0HNQ2IQ4BPVBC", Request id "0HNQ2IQ4BPVBC:00000001": An unhandled exception was thrown by the application.
      System.InvalidOperationException: Headers are read-only, response has already started.

The moment the endpoint started writing JSON, Kestrel sent the status line and headers over the wire; headers go before the body and can't be taken back. By the time await next(context) returned, the response had long started, and trying to change a header threw. The developer exception page couldn't step in either ("The response has already started, the error page middleware will not be executed."). You can check context.Response.HasStarted, but the real fix is to write the header at the right moment:

public class ResponseTimeMiddleware(RequestDelegate next)
{
    public async Task InvokeAsync(HttpContext context)
    {
        long start = Stopwatch.GetTimestamp();

        // Runs right before the headers are sent
        context.Response.OnStarting(() =>
        {
            TimeSpan elapsed = Stopwatch.GetElapsedTime(start);
            context.Response.Headers["X-Response-Time-Ms"] = elapsed.TotalMilliseconds.ToString("F1", CultureInfo.InvariantCulture);
            return Task.CompletedTask;
        });

        await next(context);
    }
}

The OnStarting callback runs right before the headers are sent; the X-Response-Time-Ms you saw in the curl outputs above comes from here. I wrote CultureInfo.InvariantCulture on purpose too: this Mac's culture is Turkish, and without it the header goes out as 2,7. Its sibling OnCompleted runs after the response has fully reached the client; you can't change the response any more, but it is a good place for logging and cleanup:

context.Response.OnCompleted(() =>
{
    app.Logger.LogInformation("Response for {OrderNumber} finished with {StatusCode}", orderNumber, context.Response.StatusCode);
    return Task.CompletedTask;
});

Items, Features and Per-Request Data

Every request from an institutional customer's units (campus, library, department) carries the unit number in the X-Tenant-Id header. Instead of reading it separately in every endpoint, we resolve it once in a middleware and put it into Items:

public class TenantMiddleware(RequestDelegate next, InstitutionDirectory directory)
{
    public const string HeaderName = "X-Tenant-Id";
    private static readonly object UnitKey = new();

    public async Task InvokeAsync(HttpContext context)
    {
        // Only institutional endpoints need a tenant
        if (!context.Request.Path.StartsWithSegments("/api/institution"))
        {
            await next(context);
            return;
        }

        string? raw = context.Request.Headers[HeaderName];
        if (!int.TryParse(raw, out int unitId) || directory.Find(unitId) is not { } unit)
        {
            context.Response.StatusCode = StatusCodes.Status400BadRequest;
            await context.Response.WriteAsJsonAsync(new { error = $"Missing or unknown {HeaderName} header." });
            return;
        }

        context.Items[UnitKey] = unit;
        await next(context);
    }

    public static InstitutionUnit GetUnit(HttpContext context) =>
        context.Items[UnitKey] as InstitutionUnit
            ?? throw new InvalidOperationException("TenantMiddleware did not run for this request.");
}
$ curl localhost:5238/api/institution/profile
{"error":"Missing or unknown X-Tenant-Id header."}

$ curl localhost:5238/api/institution/profile -H "X-Tenant-Id: 102"
{"id":102,"institutionName":"Riverside School","name":"Library"}

Items is an IDictionary<object, object?> and lives only as long as the request. I used a private object as the key instead of a string: there is no chance of colliding with another library's "unit" key, and the only way to read it is the GetUnit method. Trusting this header blindly is not right, of course; in a real app the unit number should be checked against a claim in the token. You can do that with the resource-based authorization from the authorization post.

Features are the capabilities the server and middleware attach to the request. You usually reach most of them indirectly through the properties of HttpContext; asking for them directly looks like this:

app.MapGet("/api/debug/features", (HttpContext context) =>
{
    IHttpConnectionFeature connection = context.Features.GetRequiredFeature<IHttpConnectionFeature>();
    IHttpMaxRequestBodySizeFeature? bodySize = context.Features.Get<IHttpMaxRequestBodySizeFeature>();
    return new
    {
        connection.ConnectionId,
        connection.LocalPort,
        MaxRequestBodySize = bodySize?.MaxRequestBodySize,
        BodySizeIsReadOnly = bodySize?.IsReadOnly,
        HasStarted = context.Response.HasStarted,
        Authenticated = context.User.Identity?.IsAuthenticated,
    };
});
$ curl localhost:5238/api/debug/features
{"connectionId":"0HNQ2IQA1K6BU","localPort":5238,"maxRequestBodySize":30000000,"bodySizeIsReadOnly":false,"hasStarted":false,"authenticated":false}

Kestrel's default body limit of 30,000,000 bytes shows up here. As long as IsReadOnly is false, that is before the body starts being read, you can change this limit for a single request; it is the low-level version of the attribute setting from the file upload post. On the response side there are features such as IHttpResponseBodyFeature.DisableBuffering(); streaming endpoints like Server-Sent Events rely on them. TraceIdentifier in Kestrel is made of the connection ID and the request sequence number, and lets you find the same request in the logs. We saw RequestAborted in the async and CancellationToken post; in a minimal API a CancellationToken parameter binds straight to it.

IHttpContextAccessor: When, and Why with Care

In an endpoint HttpContext is right at hand. But how does a service nested inside another service, the one that writes the order audit record, get hold of it? IHttpContextAccessor, registered with AddHttpContextAccessor(), provides that:

public class OrderAudit(IHttpContextAccessor accessor, ILogger<OrderAudit> logger)
{
    public void Record(string orderNumber)
    {
        // The accessor gives a non-null HttpContext only while a request is running
        HttpContext context = accessor.HttpContext
            ?? throw new InvalidOperationException("OrderAudit must be used inside a request.");

        logger.LogInformation("Order {OrderNumber} placed by unit {UnitId} (trace {TraceId})",
            orderNumber, TenantMiddleware.GetUnit(context).Id, context.TraceIdentifier);
    }
}
info: Bookstore.Api.Institutional.OrderAudit[0]
      Order INS-101-4047 placed by unit 101 (trace 0HNQ2IPUMTQPM:00000001)
info: Bookstore.Api[0]
      Response for INS-101-4047 finished with 201

It works, but it has a price. OrderAudit is now tied to HTTP; to call it from a background job or a unit test you have to set up a fake context. The accessor works through AsyncLocal, so every access has a small cost too. Learn's advice points the same way: where possible, read the value you need (unit number, user ID) in the endpoint and pass it to the method as a parameter. Keep the accessor for places called by framework code you can't step into.

The real danger is touching HttpContext after the request has finished. Look at this code that wants to send the order notification "in the background":

app.MapPost("/api/institution/orders/{orderNumber}/notify", (string orderNumber, HttpContext context) =>
{
    _ = Task.Run(async () =>
    {
        await Task.Delay(500);
        try
        {
            app.Logger.LogInformation("Notifying unit about {OrderNumber} for {Path}", orderNumber, context.Request.Path);
        }
        catch (Exception ex)
        {
            app.Logger.LogError(ex, "Background notification failed");
        }
    });
    return TypedResults.Accepted((string?)null);
});
fail: Bookstore.Api[0]
      Background notification failed
      System.ObjectDisposedException: IFeatureCollection has been disposed.
      Object name: 'Collection'.

The 202 came back immediately, and half a second later the HttpContext was gone. Kestrel reuses these objects for later requests; with bad luck you read another request's data instead of getting an exception. When I tried the same thing with the accessor there was no exception, but accessor.HttpContext was null. The rule is simple: give background work copies of the values it needs, not the HttpContext, and hand the work to a real queue or hosted service. We cover background jobs in detail in the Background Jobs and Scheduled Jobs series.

Common Pitfalls

First, adding a header after the response has started. We saw it in the guess: the client gets a 200, the header silently disappears, and the error shows up only in the server log. Write the header inside OnStarting or add it before next.

Second, reading the body twice without buffering. The first reader consumes the stream and the second finds it empty; a minimal API reports that as a 400 and you only see why in Development. Don't forget the pair EnableBuffering and Position = 0.

Third, keeping HttpContext in a singleton. Assign accessor.HttpContext to a field of a singleton and that field holds the first request's context; on later requests you look at either a disposed object or another user's data. Injecting IHttpContextAccessor into a singleton is safe; storing its value is not.

Fourth, trusting X-Forwarded-For too freely. Clear the KnownProxies and KnownIPNetworks lists to "accept from anywhere" and you hand every IP-based check to the client.

A Small Experiment

★☆☆ Delete the context.Request.Body.Position = 0; line from RequestBodyLoggingMiddleware. While the log line still appears, check which status code the endpoint returns and that the response body is empty in the Production environment.

★★☆ Use your own feature instead of Items in TenantMiddleware: define an interface called IInstitutionUnitFeature, add it with context.Features.Set(...) and read it in the endpoint with context.Features.Get<IInstitutionUnitFeature>(). Compare how readable the two approaches are.

★★★ Lower the body limit of the /api/cart/items endpoint to 1 KB with IHttpMaxRequestBodySizeFeature and send a 2 KB body. Observe which status code you get and what happens when you try to change the limit after the body has been read.

Reading HttpContext directly is always possible, but a minimal API does most of the work for you through parameters. In the next post we go inside that binding mechanism: how it decides where each parameter comes from, binding your own types with TryParse and BindAsync, [AsParameters] and form binding.