Kestrel and Request Limits: Body Size, Header Timeout, Request Timeouts and Settings Against Slow Clients
By default Kestrel allows a 28.6 MB body, 30 seconds for headers and unlimited request duration, and a request timeout stops nothing unless the CancellationToken is carried. We read the real defaults from code and, with measurements, cover body, header, form and JSON limits, RequestHeadersTimeout and MinRequestBodyDataRate against slow clients, the request timeouts middleware and the split of work between Kestrel and the reverse proxy.
Version note: The code in this post was built and run with .NET 10 (ASP.NET Core 10.0.13 runtime); the slow-client measurements were made with a small Python 3 script, against the local app only.
Guess First
We added .WithRequestTimeout(TimeSpan.FromSeconds(2)) to Bookstore's report endpoint. Inside the endpoint there is a 5-second wait standing in for a database query, but we forgot to pass the CancellationToken to the query. What does the client get: a 504 after 2 seconds or a 200 after 5 seconds? Second question: with Kestrel's default settings, how long can a client that sends its headers one byte per second keep the connection open?
The answers are "a 200 after 5 seconds" and "30 seconds". Both are the subject of this post: the settings that limit how many resources Kestrel and ASP.NET Core give to a request. We saw the basic limits for file uploads in the ASP.NET Core Web API (Application Programming Interface) series post "File Upload and Download", and carrying the CancellationToken down to the database in the same series' "Async/Await and CancellationToken" post. Here we look at the whole picture: which limit lives where, what its default is, what the client sees when it is exceeded and what lands in the log.
The Defaults: How Tolerant Is Kestrel?
Values in the documentation sometimes come from older versions, so I read the defaults directly from new KestrelServerLimits(), new FormOptions() and new RequestTimeoutOptions() on .NET 10:
MaxRequestBodySize 30,000,000
MaxRequestHeadersTotalSize 32,768
MaxRequestHeaderCount 100
MaxRequestLineSize 8,192
MaxRequestBufferSize 1,048,576
RequestHeadersTimeout 00:00:30
KeepAliveTimeout 00:02:10
MinRequestBodyDataRate 240 B/s, grace 00:00:05
MinResponseDataRate 240 B/s, grace 00:00:05
MaxConcurrentConnections null
Http2.MaxStreamsPerConnection 100
Http2.MaxRequestHeaderFieldSize 32,768
Form.ValueCountLimit 1,024
Form.ValueLengthLimit 4,194,304
Form.KeyLengthLimit 2,048
Form.MultipartBodyLengthLimit 134,217,728
Form.MultipartHeadersLengthLimit 16,384
RequestTimeoutOptions.DefaultPolicy null
RequestTimeoutPolicy.TimeoutStatusCode null
JsonSerializerOptions.Web.MaxDepth 0 (0 = 64)
Two lines differ from what older versions of the Kestrel page on Microsoft Learn say: KeepAliveTimeout is 130 seconds, not 2 minutes, and MaxRequestHeaderFieldSize for HTTP/2, the second version of HTTP (Hypertext Transfer Protocol), is 32 KB, not 8 KB. The summary of the table: by default a request may carry a body of about 28.6 MB and 32 KB of headers, may take 30 seconds to finish its headers, there is no cap on concurrent connections and there is no request timeout at all. The Bookstore API needs none of that, so we tighten it.
Body Size: A Global Limit and an Endpoint Exception
The rule: the global limit is small, sized for the request the app sees most often; endpoints that expect large bodies are marked explicitly.
builder.WebHost.ConfigureKestrel(options =>
{
options.Limits.MaxRequestBodySize = 1 * 1024 * 1024;
options.Limits.MaxRequestHeadersTotalSize = 16 * 1024;
options.Limits.RequestHeadersTimeout = TimeSpan.FromSeconds(5);
options.Limits.MinRequestBodyDataRate = new(bytesPerSecond: 240, gracePeriod: TimeSpan.FromSeconds(5));
options.Limits.KeepAliveTimeout = TimeSpan.FromSeconds(30);
});
builder.Services.Configure<FormOptions>(options =>
{
options.ValueCountLimit = 200;
options.MultipartBodyLengthLimit = 10 * 1024 * 1024;
});
builder.Services.ConfigureHttpJsonOptions(options => options.SerializerOptions.MaxDepth = 16);
builder.Services.AddRequestTimeouts(options =>
{
options.DefaultPolicy = new RequestTimeoutPolicy { Timeout = TimeSpan.FromSeconds(10) };
options.AddPolicy("reports", TimeSpan.FromSeconds(2));
});
builder.Services.AddValidation();
The 5 seconds for RequestHeadersTimeout is short for the demo; 10 to 15 seconds is more reasonable in a real system. The book cover upload endpoint may exceed 1 MB, so it gets its own limit:
app.MapPost("/covers", async (IFormFile cover, CancellationToken cancellationToken) =>
{
await using Stream stream = cover.OpenReadStream();
long length = 0;
byte[] buffer = new byte[81920];
int read;
while ((read = await stream.ReadAsync(buffer, cancellationToken)) > 0)
{
length += read;
}
return Results.Ok(new { cover.FileName, length });
}).WithMetadata(new RequestSizeLimitAttribute(8 * 1024 * 1024)).DisableAntiforgery();
RequestSizeLimitAttribute is an MVC (Model-View-Controller) attribute, but since it implements IRequestSizeLimitMetadata it works with minimal APIs too; the routing middleware writes the value into the request's IHttpMaxRequestBodySizeFeature when the endpoint is selected. There is also DisableRequestSizeLimit(), but "unlimited" is almost never the right answer. For multipart bodies FormOptions.MultipartBodyLengthLimit applies as well; if you make the endpoint limit larger than that, the smaller one wins. The results:
== 1.7 MB body
413
== 3 MB cover
{"fileName":"cover.bin","length":3145728} 200
The 1.7 MB JSON (JavaScript Object Notation) request got a 413 in 0.6 ms, the moment Kestrel saw the Content-Length header, without reading the body at all. When the body was sent chunked, it also got a 413, but this time the limit was exceeded while the body was being read, and the endpoint's parameter binding saw this exception: BadHttpRequestException: Request body too large. The max request body size is 1048576 bytes. If you read the body yourself, catch this exception; otherwise it lands in the log as an "unhandled exception".
Headers, Forms and JSON
The body limit caps the total size, not the shape of the content. An order with 60,000 lines, thousands of form fields or a deeply nested JSON fit inside 1 MB, and each consumes a different resource. So we put limits on the content too:
app.MapPost("/orders", (CreateOrder order) => Results.Ok(new { items = order.Items.Count }));
app.MapPost("/search", ([FromForm] IFormCollection form) => Results.Ok(new { fields = form.Count }))
.DisableAntiforgery();
public sealed record CreateOrder([property: MaxLength(50)] List<OrderLine> Items);
public sealed record OrderLine(int BookId, [property: Range(1, 10)] int Quantity);
== 60 items
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"One or more validation errors occurred.","status":400,"errors":{"Items":["The field Items must be a string or array type with a maximum length of '50'."]},"traceId":"00-28e178ea3714432debfadb0e1475c5ab-a1a7e9cb83a58256-00"} 400
== depth 21
400
== 300 form fields
400
== 20 KB header
431
The collection limit comes with .NET 10's minimal API validation (AddValidation) and returns a meaningful ProblemDetails. But note: validation runs after the JSON has been fully deserialized; you load 60 lines and 60,000 lines into memory alike. So the collection limit does not replace the body limit, it complements it. The depth and form field count returned 400s with empty bodies; their causes show up only at Debug level:
dbug: Microsoft.AspNetCore.Http.RequestDelegateFactory[2]
Failed to read parameter "CreateOrder order" from the request body as JSON.
System.Text.Json.JsonException: The maximum configured depth of 16 has been exceeded. Cannot read next JSON object. Path: $.x | LineNumber: 0 | BytePositionInLine: 109.
dbug: Microsoft.AspNetCore.Http.RequestDelegateFactory[8]
Failed to read parameter "IFormCollection form" from the request body as form.
System.IO.InvalidDataException: Form value count limit 200 exceeded.
The default JSON depth is 64. None of Bookstore's models goes beyond 5 levels; 16 is a comfortable margin. Deeply nested JSON consumes stack in code that processes it recursively; keeping the limit low is cheap insurance. A single 20 KB header hit the 16 KB total header limit and Kestrel returned 431 (Request Header Fields Too Large). In apps whose cookies keep growing, this limit blows up in an unexpected place; a user whose cookies exceed 16 KB cannot reach the site at all.
Slow Clients: RequestHeadersTimeout and MinRequestBodyDataRate
Slowloris-style attacks open many connections with little bandwidth and feed each as slowly as possible. To measure the behavior I wrote a small Python script against the local app: one mode sends headers at one byte per second, the other sends a 10,000-byte body at 10 bytes per second:
# Only against the local Bookstore app: measures slow-client behavior.
import socket, sys, time
def run(mode, host="127.0.0.1", port=19340):
s = socket.create_connection((host, port))
s.settimeout(1)
start = time.monotonic()
if mode == "headers":
s.sendall(b"GET /orders HTTP/1.1\r\nHost: localhost\r\n")
chunks = iter(lambda: b"X", None)
else:
s.sendall(b"POST /orders HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\nContent-Length: 10000\r\n\r\n")
chunks = iter(lambda: b" " * 10, None)
while True:
try:
data = s.recv(4096)
if not data:
print(f"{mode}: connection closed by server after {time.monotonic() - start:.1f}s")
return
print(f"{mode}: after {time.monotonic() - start:.1f}s got {data.splitlines()[0].decode()}")
return
except socket.timeout:
try:
s.sendall(next(chunks))
except OSError as e:
print(f"{mode}: send failed after {time.monotonic() - start:.1f}s ({e.__class__.__name__})")
return
run(sys.argv[1])
headers: after 6.1s got HTTP/1.1 408 Request Timeout
body: after 5.9s got HTTP/1.1 408 Request Timeout
dbug: Microsoft.AspNetCore.Server.Kestrel.BadRequests[17]
Connection id "0HNPTBL9PQ21L" bad request data: "Reading the request headers timed out."
dbug: Microsoft.AspNetCore.Server.Kestrel.BadRequests[27]
Connection id "0HNPTBL9PQ21M", Request id "0HNPTBL9PQ21M:00000001": the request timed out because it was not sent by the client at a minimum of 240 bytes/second.
The header timeout was set to 5 seconds and the connection closed at 6.1 seconds: Kestrel checks its timers once per second, so a one-second drift is normal. For the body, the rate was not checked during the 5-second grace period, and at the first check after it the request, below 240 B/s, was cut off with 408. Both events go to the Microsoft.AspNetCore.Server.Kestrel.BadRequests category at Debug level; keeping that category at Information in production and watching its count is the cheapest way to see a slow-client attack. With the default values, the same client could have held the connection for 30 seconds in the header phase. Remember that these timeouts are not enforced while a debugger is attached; it is easy to test in Visual Studio and conclude "it doesn't work".
The Request Timeouts Middleware and CancellationToken
Kestrel's limits guard against the client's slowness; against the app's own slowness there is the request timeouts middleware that arrived with .NET 8. We attached a 2-second policy to the report endpoint:
app.UseRequestTimeouts();
app.MapGet("/reports/sales", async (ReportService reports, CancellationToken cancellationToken) =>
Results.Ok(await reports.BuildAsync(cancellationToken)))
.WithRequestTimeout("reports");
public sealed class ReportService(ILogger<ReportService> logger)
{
public async Task<object> BuildAsync(CancellationToken cancellationToken)
{
try
{
// stands in for the database query
await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken);
return new { total = 42 };
}
catch (OperationCanceledException)
{
logger.LogWarning("Sales report cancelled after timeout");
throw;
}
}
}
== report timeout
504 2.009847s
warn: ReportService[0]
Sales report cancelled after timeout
warn: Microsoft.AspNetCore.Http.Timeouts.RequestTimeoutsMiddleware[1]
Timeout exception handled.
With the token carried down to the query, the request ended with a 504 after 2 seconds and the query was cancelled. Now the opening question: when I wrote the same endpoint without passing the token, the result was {"total":42} 200 5.072117s. The middleware does not kill the request; it only swaps HttpContext.RequestAborted for a token that gets cancelled. Code that does not listen to the token runs to the end, as if there were no timeout. The default status code is 504 (when TimeoutStatusCode is left null); if the response has already started, the middleware cannot change the status code. So the real effect of the timeout depends on how disciplined the code is about carrying the CancellationToken. With DefaultPolicy we put a 10-second ceiling on every endpoint; with DisableRequestTimeout() you can deliberately exclude streaming and SSE (Server-Sent Events) endpoints.
Which Limit Where: Kestrel or the Reverse Proxy?
| Concern | Kestrel / ASP.NET Core | Reverse proxy (Nginx) |
|---|---|---|
| Body size | MaxRequestBodySize, endpoint metadata | client_max_body_size (default 1m) |
| Slow headers | RequestHeadersTimeout | client_header_timeout (default 60s) |
| Slow body | MinRequestBodyDataRate | client_body_timeout (default 60s) |
| Form and JSON shape | FormOptions, MaxDepth, validation | None, it does not know the content |
| App duration | Request timeouts middleware | proxy_read_timeout (returns 504, closes the upstream connection) |
The reverse proxy applies coarse limits early and absorbs slow clients before they reach the app; because Nginx by default buffers the whole body before sending it upstream, Kestrel never sees the slow body. Limits about the content (form field count, JSON depth, collection size), however, can only be set in the app. Neither replaces the other; keeping the same limit consistent across both layers (inconsistencies like 10 MB in Nginx and 1 MB in Kestrel return different error codes to the client) is the subject of the Web Servers and Hosting series.
Production Notes
The most dangerous setup is leaving the limits to the proxy alone while Kestrel is also directly reachable. If the container's port was exposed by mistake, or another service on the same network can connect to Kestrel directly, every limit in the proxy is bypassed. So Kestrel's limits should not stay at their defaults on the assumption that "the proxy already does it"; they should be reasonable on their own.
Watch the counts of 413, 431 and 408. Requests that reach the app (413s returned by the Content-Length check, 408s cut off during the body phase) show up in the status code dimension of the http.server.request.duration metric; requests cut off during the header phase never reach the app, so you track them through Kestrel's BadRequests log category. 413s are normally client errors; a sudden rise means either a new client release sends bigger requests or someone is probing the limits.
Do not forget the clients when changing limits: if an old release of the mobile app sends 2 MB requests, lowering the global limit to 1 MB breaks that release. Measure the distribution of current body sizes first, then set the limit.
Common Pitfalls
Relying on a request timeout without carrying the CancellationToken: as shown above, you get a late 200 instead of a 504, and the database load never drops.
Solving file uploads by setting MaxRequestBodySize to null (unlimited). The right way is to give a higher limit to the upload endpoint only and read the file as a stream instead of into memory.
Thinking the form limits are just MultipartBodyLengthLimit. There are also ValueCountLimit and ValueLengthLimit; a large form can keep the server busy with thousands of fields without hitting the body limit.
Testing timeouts with a debugger attached. RequestHeadersTimeout, MinRequestBodyDataRate and the request timeouts middleware are not enforced while a debugger is attached.
A Small Experiment
★☆☆ Lower MaxRequestHeadersTotalSize to 8 KB and send a request from the browser with a large cookie. Inspect Kestrel's response and log; how would you explain this to the user in the app?
★★☆ Add a --connections 200 parameter to the Python script and open 200 slow connections at once. Repeat with MaxConcurrentConnections set to 100 and measure what a normal request experiences (against your local app only).
★★★ Turn the report endpoint into a version that streams rows one by one with IAsyncEnumerable. What happens if the request timeout fires after the response has started? Observe it in terms of status code, connection and log.
With this post we have limited the volume, content and duration of requests. In the next post we look at how these limits, and all the security decisions from the earlier posts, show up in production: authentication, authorization and Identity metrics, audit trails and alerts.