Updated on

The request body arrives as a forward-only stream, and whoever reads it first is usually the only one who gets it. That single fact decides everything about reading the request body in an ASP.NET Core Web API: where we can read it, whether model binding still works afterwards, and why a second read comes back empty.

To download the source code for this article, you can visit our GitHub repository.

We will cover four places to do it: a controller action, custom middleware, an action filter, and a custom attribute. Request.EnableBuffering() is what lets more than one of them succeed on the same request.

How Do We Read the Request Body in a Controller Action?

Request.Body inside a controller action is the raw request stream. It is forward-only and sits at the end once anything has read it, which is why a second read comes back empty rather than throwing.

Reading it once is a StreamReader away. Reading it twice takes Request.EnableBuffering(), which swaps the stream for one that can be rewound, plus a Request.Body.Position = 0 between the two reads.

The alternative is to let the framework read it for us. A parameter marked [FromBody] is deserialized into our own type by an input formatter before the action runs, so we work with an object, not a stream.

The two do not mix by accident. Once model binding has read the body for a [FromBody] parameter, the stream inside that same action is empty, unless it was buffered earlier and then rewound.

So the choice inside an action is simple: take the raw text, or take a bound model. Wanting both in one action is what EnableBuffering() exists for.

Everything below runs in an ordinary ASP.NET Core Web API controller, with no extra packages involved.

ReadAsStringAsync() Extension Method

Rather than directly converting the stream data to a string within the controller action, we can implement an extension method. This approach allows us to use it in various scenarios. As we progress through this article, there will be a recurrent need to convert stream data to a string. Leveraging extension methods provides an efficient solution to implement once and employ multiple times.

Let’s implement the extension method:

public static class RequestExtensions
{
    public static async Task<string> ReadAsStringAsync(this Stream requestBody, bool leaveOpen = false)
    {
        using StreamReader reader = new(requestBody, leaveOpen: leaveOpen);
        var bodyAsString = await reader.ReadToEndAsync();

        return bodyAsString;
    }
}

We establish an extension method named ReadAsStringAsync(). This method enhances the functionalities of the Stream type, enabling us to convert stream data into a string format. To achieve this, we create an instance of the StreamReader class. Utilizing the StreamReader class facilitates the reading of characters from a stream, be it a file or a network stream.

For a deeper understanding of StreamReader and StreamWriter, we recommend consulting our article on reading and writing files with StreamWriter and StreamReader.

Despite the numerous constructor options available, in our context, we provide two parameters for the StreamReader. First is the stream data representing our request body and the second parameter, leaveOpen. This parameter ensures that the stream remains open even after the StreamReader completes its operations and is disposed.

In the subsequent step, we invoke the ReadToEndAsync() method of the reader object, which yields the string representation of the stream data.

Reading as String

We can read the request body as a string in a controller action. ASP.NET Core offers the Request property within controller actions, granting access to the Request.Body. However, this body has a Stream type and is unreadable to us. To handle this stream data as text, we need to convert it into a string format:

[HttpPost("read-as-string")]
public async Task<IActionResult> ReadAsString()
{
    var requestBody = await Request.Body.ReadAsStringAsync();

    return Ok($"Request Body As String: {requestBody}");
}

Here, we access the request body by invoking the ReadAsStringAsync() extension method we implemented above. Once we obtain the response from the extension method, we straightforwardly return it from our action for testing purposes.

Using EnableBuffering for Multiple Reads

What occurs if we attempt to read the request body once more or multiple times in the above scenario?

Let’s check this:

[HttpPost("read-as-string-multiple")]
public async Task<IActionResult> ReadAsStringMultiple()
{
    var requestBody = await Request.Body.ReadAsStringAsync();
    var requestBodySecond = await Request.Body.ReadAsStringAsync();

    return Ok($"First: {requestBody}, Second:{requestBodySecond}");
}

Now, let’s send the string “CodeMaze” to this action method as the request payload and inspect the response:

First: CodeMaze, Second:

The first read attempt is successful, but the second one is not what we expect.

There is no Swagger UI page to click through here, because the .NET 10 Web API template ships Microsoft.AspNetCore.OpenApi and no Swashbuckle, so the project generates an OpenAPI document and no interactive page. That document is generated from every mapped endpoint, and keeping an endpoint out of the generated OpenAPI document is its own short job.

In situations requiring multiple reads of the request body, enabling buffering is essential. To achieve this, we can utilize the EnableBuffering() method of the Request object:

[HttpPost("read-multiple-enable-buffering")]
public async Task<IActionResult> ReadMultipleEnableBuffering()
{
    Request.EnableBuffering();
    var requestBody = await Request.Body.ReadAsStringAsync(true);

    Request.Body.Position = 0;
    var requestBodySecond = await Request.Body.ReadAsStringAsync();

    return Ok($"First: {requestBody}, Second:{requestBodySecond}");
}

Here, we invoke the Request.EnableBuffering() method, allowing the reading of the request body multiple times. Following that, we invoke the ReadAsStringAsync() method. To ensure the stream remains open for subsequent reads, we set the leaveOpen parameter to true. Just before the second attempt, we reset the position of the request body to zero.

With the latest modifications in place, let’s test the API with the same payload:

First: CodeMaze, Second:CodeMaze

After invoking the EnableBuffering() method, we effectively retrieve the request body on the second attempt as well.

Model Binding

ASP.NET Core allows automatic deserialization of the request body to a predefined model class. This approach simplifies the handling of structured data. To make use of this intriguing feature, we utilize the [FromBody] attribute within our action, preceding the model parameter:

[HttpPost("read-from-body")]
public IActionResult ReadFromBody([FromBody] PersonItemDto model)
{
    var message = $"Person Data => Name: {model.Name}, Age: {model.Age}";

    return Ok(message);
}

Here, ASP.NET Core automatically maps the incoming request body to the PersonItemDto type. Then, within the action body, we have the ability to access and utilize the properties of the model. Whether the payload satisfied our rules is a separate question, and checking whether the model actually bound is what ModelState is for.

We are free to design the PersonItemDto type and its properties to match our application’s needs precisely:

public record PersonItemDto(string Name, int Age);

Let’s explore one more scenario before bidding farewell to this topic. Let’s say we want to send and read an extra salary parameter to our action method. This next version does not work, and it is worth seeing why:

[HttpPost("read-from-body-multi-param")]
public IActionResult ReadFromBodyMultiParam([FromBody] PersonItemDto model, [FromBody] decimal salary)
{
    var message = $"Person Data => Name: {model.Name}, Age: {model.Age}, Salary: {salary}";

    return Ok(message);
}

There are no compilation errors when we build the code. However, during the runtime, when our application attempts to execute, an InvalidOperationException occurs at the line app.MapControllers() in the Program.cs file:

Action 'ReadingRequestBody.Controllers.HomeController.ReadFromBodyMultiParam (ReadingRequestBody)'
has more than one parameter that was specified or inferred as bound from request body. Only one
parameter per action may be bound from body. Inspect the following parameters, and use
'FromQueryAttribute' to specify bound from query, 'FromRouteAttribute' to specify bound from route,
and 'FromBodyAttribute' for parameters to be bound from body:

In simple terms, this exception notifies us that the FromBody attribute is permitted only once in action method parameters. Hence, it is advisable to gather all parameters within a single request model parameter.

Why Can Only One Parameter Be Bound From the Request Body?

An action may have at most one parameter bound from the request body. Under [ApiController], a second one throws InvalidOperationException while the application starts, on the app.MapControllers() line, before requests arrive.

The reason is the stream again. Microsoft’s model binding documentation states it directly: once the request stream is read by an input formatter, it is no longer available to be read again for binding other [FromBody] parameters.

The [ApiController] attribute makes this easier to hit by accident, because it infers a body source for complex-typed parameters without us writing [FromBody] at all. Two complex parameters on one action is enough to trigger it.

The fix is to gather everything the payload carries into one type and bind that. Values that are not really part of the payload belong elsewhere: [FromRoute] for identifiers in the path, [FromQuery] for filters, [FromHeader] for metadata.

The exception message points at FromQueryAttribute and FromRouteAttribute by name, for exactly that reason.

The Microsoft model binding documentation is blunt about it: “Once the request stream is read by an input formatter, it’s no longer available to be read again for binding other [FromBody] parameters.”

Inference is what turns this from a rule into a trap, so it is worth knowing exactly when the ApiController attribute picks a binding source for us.

How Do We Read the Request Body in Custom Middleware?

Middleware sees the request before model binding and before any filter. That makes it the earliest place we can read the body, and the only place that can turn on buffering for everything downstream.

It is the natural home for work that applies to every request: logging payloads, auditing, computing a signature, rejecting a request outright before MVC ever sees it.

Two lines make it safe for the rest of the pipeline. Request.EnableBuffering() replaces the body with a rewindable stream, and Request.Body.Position = 0 puts it back at the start once we are done.

Skip the rewind and every [FromBody] parameter downstream binds against an empty stream, with no exception to explain why.

Buffering is not free. Called with no arguments, EnableBuffering() keeps up to 30 KB in memory and writes anything larger to a temporary file on disk, so a middleware that buffers every request buffers every upload too.

We will not go into the details of middleware here. For a refresher, see our article on creating flexible application flows with ASP.NET Core middleware.

Let’s see how to create a custom middleware to read the request body:

public class RequestBodyMiddleware(RequestDelegate next, ILogger<RequestBodyMiddleware> logger)
{
    private const int MaxContentLength = 1024;

    public async Task Invoke(HttpContext context)
    {
        await next(context);
    }
}

Here, we create a custom middleware named RequestBodyMiddleware. The constructor takes ILogger<RequestBodyMiddleware> rather than the non-generic ILogger, which the container cannot resolve on its own: ask for that one and the application throws while it starts.

Let’s now implement the Invoke() method to access and read the request body:

public async Task Invoke(HttpContext context)
{
    var requestPath = context.Request.Path.Value ?? string.Empty;

    if (requestPath.Contains("read-from-middleware", StringComparison.OrdinalIgnoreCase))
    {
        if (context.Request.ContentLength > MaxContentLength)
        {
            context.Response.StatusCode = StatusCodes.Status413PayloadTooLarge;
            await context.Response.WriteAsync("Request Body Too Large");

            return;
        }

        context.Request.EnableBuffering(bufferThreshold: MaxContentLength, bufferLimit: MaxContentLength);
        var requestBody = await context.Request.Body.ReadAsStringAsync(true);

        logger.LogInformation("Request Body:{@requestBody}", requestBody);
        context.Request.Headers.Append("RequestBodyMiddleware", requestBody);
        context.Items.Add("RequestBody", requestBody);
        context.Request.Body.Position = 0;
    }

    await next(context);
}

We examine the request path to identify specific routes initially. After that, we translate the request body stream into a raw string representation by calling our extension method ReadAsStringAsync(). Following this, numerous options are available for leveraging the request body: logging the result, appending it to the request header, or passing it down the pipeline in HttpContext.Items, among other potential uses. After concluding our processing of the request body, we direct the request to the subsequent middleware by invoking the next() delegate.

To utilize this middleware, it’s essential to incorporate it in the Program.cs file:

var app = builder.Build();
app.UseMiddleware<RequestBodyMiddleware>();

Avoid Reading Large Request Bodies

Handling large request bodies in a web application demands caution due to potential memory issues, performance degradation, and resource exhaustion. The risk of denial-of-service attacks through intentionally large bodies is the sharp end of it, and the guard we write decides whether we are exposed to it.

Checking requestBody.Length after the read is not a size limit. By then the whole payload is already a string in memory, which is precisely what a large-body attack wants. The limit has to come before the read, which is why our middleware asks the request header first:

if (context.Request.ContentLength > MaxContentLength)
{
    context.Response.StatusCode = StatusCodes.Status413PayloadTooLarge;
    await context.Response.WriteAsync("Request Body Too Large");

    return;
}

context.Request.EnableBuffering(bufferThreshold: MaxContentLength, bufferLimit: MaxContentLength);

ContentLength comes from the request header, so the decision costs nothing. bufferLimit is the backstop for a request that sends no length, because reading past it throws an IOException instead of filling memory or disk. With MaxContentLength at 1024, a 2,000-byte payload comes back as Payload Too Large (413) and a 500-byte one goes through with a 200.

ASP.NET Core already has limits of its own. Kestrel’s MaxRequestBodySize defaults to 30,000,000 bytes, roughly 28.6 MB, and [RequestSizeLimit] on an action or IHttpMaxRequestBodySizeFeature in middleware overrides it per endpoint or per request.

How Do We Read the Request Body in an Action Filter?

Action filters run after model binding, and that ordering decides everything about what they can see. Microsoft’s filter documentation puts resource filters before model binding and action filters after it.

So an action filter reading Request.Body on an action that takes no parameters gets the whole payload. The same filter on an action with a [FromBody] parameter gets an empty string, because the input formatter already drained the stream.

Nothing throws. The filter reads zero bytes and carries on, which makes this one of the quieter ways to lose data in a Web API.

Two changes make it reliable. Enable buffering earlier, in middleware or in a resource filter, and set Request.Body.Position = 0 in the filter before reading.

If all we want is the body before binding, a resource filter is the better hook. It lives in the same MVC pipeline, with access to the same controller and action metadata, but it runs on the near side of model binding.

To learn more about the action filters, please check out our article on writing and registering action filters.

Let’s create a custom action filter:

public class ReadRequestBodyActionFilter : IAsyncActionFilter
{
    public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
    {
        var requestPath = context.HttpContext.Request.Path.Value ?? string.Empty;

        if (requestPath.Contains("read-from-action-filter", StringComparison.OrdinalIgnoreCase))
        {
            var requestBody = await context.HttpContext.Request.Body.ReadAsStringAsync();
            context.HttpContext.Request.Headers.Append("ReadRequestBodyActionFilter", requestBody);
        }

        await next();
    }
}

In this scenario, we create a custom action filter called ReadRequestBodyActionFilter that implements the IAsyncActionFilter interface. Within this filter, we define the OnActionExecutionAsync() method to handle our specific logic. Then, we examine the request path and extract the request body using the ReadAsStringAsync() extension method. Lastly, we append the request body to the request header using the key ReadRequestBodyActionFilter.

To utilize this action filter, it’s essential to register it in the Program.cs file:

builder.Services.AddControllers(options =>
{
    options.Filters.Add<ReadRequestBodyActionFilter>();
});

How Do We Read the Request Body With a Custom Attribute?

To intercept incoming requests, a custom attribute can be used in combination with action filters to modify the behavior of the request processing pipeline.

Let’s create a custom attribute to inspect and read the request body:

[AttributeUsage(AttributeTargets.Method)]
public class ReadRequestBodyAttribute : Attribute, IAsyncActionFilter
{
    public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
    {
        var requestBody = await context.HttpContext.Request.Body.ReadAsStringAsync();
        context.HttpContext.Request.Headers.Append("ReadRequestBodyAttribute", requestBody);

        await next();
    }
}

Here, we create a custom attribute called ReadRequestBodyAttribute that implements IAsyncActionFilter, and read the body in OnActionExecutionAsync() through the same ReadAsStringAsync() extension method as before. We then append it to the request headers under the key ReadRequestBodyAttribute. Apart from the filter interface, this is an ordinary custom attribute.

The timing is identical to the previous section, so the same caution applies: this runs after model binding, and it needs buffering enabled earlier if the action also binds a parameter from the body. What the attribute adds is scope. An action filter registered in AddControllers() runs for every action and has to guard on the path, while an attribute runs only where it is applied.

We can now proceed to utilize our custom attribute. To do so, we need to apply it to our controller action:

[ReadRequestBody]
public IActionResult ReadFromAttribute()
{
    var requestBody = Request.Headers["ReadRequestBodyAttribute"];
    var message = $"Request Body From Attribute : {requestBody}";

    return Ok(message);
}

Here, we apply our custom attribute ReadRequestBody to the controller action ReadFromAttribute(). Within the action, we inspect the request header ReadRequestBodyAttribute and assign its content to the action response.

Which Approach Should We Use to Read the Request Body?

Pick by where the body still exists and how wide the concern is.

Middleware for anything that applies to every request: logging, auditing, size limits. It runs first, it is the only place that can enable buffering for the whole pipeline, and it can end a request before MVC starts.

A resource filter for the same job scoped to MVC, when we want the controller and action metadata but still need the body before model binding takes it.

An action filter or a custom attribute when reading the body is a cross-cutting concern across a handful of actions. Both run after model binding, so both need buffering turned on somewhere earlier when the action binds its body.

The controller action itself when only that one action cares, and [FromBody] whenever the payload has a shape worth deserializing.

The ordering behind all of this is the filter pipeline itself, and it is worth seeing how filters are ordered around model binding before picking a hook.

Where we read itWhen it runsWhat it sees by defaultUse it for
Custom middlewareBefore model binding and every filterThe full body, firstLogging, auditing, size limits, anything applying to every request
Resource filter (IAsyncResourceFilter)After authorization, before model bindingThe full bodyEnabling buffering inside MVC, where controller and action metadata are available
Model binding ([FromBody])Between resource filters and action filtersA deserialized object, not a streamAny payload that has a shape
Action filter (IAsyncActionFilter)After model bindingNothing, if the action has a [FromBody] parameterCross-cutting work over a group of actions
Custom attribute implementing IAsyncActionFilterAfter model binding, same as aboveSame as aboveThe same work, scoped to individual actions instead of registered globally
The controller action itselfLastThe body, unless a parameter was bound from itOne action that needs the raw text

Conclusion

In this article, we have explored various methods to answer how to read the request body in an ASP.NET Core Web API application. Retrieving the request body by reading in the controller actions offers simplicity and control for basic scenarios.

Custom middleware can be used when we want extensive global interception abilities. This can allow us to log the request body inside only one place of the Web API endpoints. Also, custom middleware allows easy manipulation of both requests and responses in one place.

Action filters are a good candidate to encapsulate logic, enhancing the clarity and focus of controller actions. By using action filters, we abstract the intricacies of handling the request body. This allows the controller action to maintain a cleaner and more dedicated focus on its primary purpose.

We also leverage custom attributes for specialized, declarative handling. This empowers us with precise control over the processing of request bodies. The suitability of each approach depends on the specific requirements of our application, spanning from fundamental control to the demand for encapsulation and specialization.

Whichever we pick, the deciding factor is the same: how far down the pipeline the body is still there to read.

Tested with .NET 10.0.10.