File Uploads and Form Data

Accept files with IFormFile and form binding, set size limits deliberately, stream large uploads and store files without trusting client names.

Syntaxapp.MapPost("/invoices/{id:int}/attachments", async (int id, IFormFile file, CancellationToken ct) => ...) .DisableAntiforgery() .WithMetadata(new RequestSizeLimitAttribute(10 * 1024 * 1024));

A multipart upload binds to IFormFile (one file, matched by form field name), IFormFileCollection (all files) or IFormCollection (fields and files). Complex types bind from form fields with [FromForm]. Before the handler runs, the framework reads the entire multipart body. The first 64 KB are buffered in memory (FormOptions.MemoryBufferThreshold) and the rest goes to a temp file, in ASPNETCORE_TEMP if set, otherwise the system temp folder. Your handler then reads file.OpenReadStream() from that buffer.

Antiforgery comes with form binding

Any endpoint that binds IFormFile or [FromForm] is marked as requiring antiforgery validation. That leaves two correct setups:

ClientSetup
Browser form with cookie authenticationAddAntiforgery() plus app.UseAntiforgery() (after authentication and authorization); the form posts the token field, and a request without it gets 400
API called with a bearer token or API key.DisableAntiforgery() on the endpoint or group: without cookies there is nothing to forge

Doing neither fails at runtime: every request to the endpoint throws InvalidOperationException (the endpoint has antiforgery metadata but no middleware) and the client sees 500.

Size limits, outermost first

LimitDefaultOver it
Reverse proxy (nginx client_max_body_size)1 MB in nginx413 from the proxy; the app never sees it
Kestrel MaxRequestBodySize30,000,000 bytes (about 28.6 MB)413
FormOptions.MultipartBodyLengthLimit128 MB400

Set the effective limit per endpoint with .WithMetadata(new RequestSizeLimitAttribute(bytes)) (namespace Microsoft.AspNetCore.Mvc) rather than raising Kestrel's global limit, and set the form limit on the same endpoint with .WithMetadata(new RequestFormLimitsAttribute { MultipartBodyLengthLimit = bytes }). Keep Configure<FormOptions> for app-wide defaults only: it caps every form endpoint. Check file.Length too: an empty file is valid multipart.

Streaming large files

Buffering is fine for invoices and avatars. For video or multi-gigabyte imports, it doubles the I/O and fills the temp disk. Bind HttpRequest instead and walk the body with MultipartReader from Microsoft.AspNetCore.WebUtilities, copying each section straight to blob storage. Nothing is buffered, so you must enforce limits while copying.

Never trust the client's file

  • FileName is whatever the client sent, for example ..\..\C:\etc/invoice.pdf. Never use it as a path. Store under a generated name such as {Guid}.pdf, and keep a sanitized original name only for display.
  • ContentType is also client input. Check the file's leading bytes (%PDF- for PDF, FF D8 FF for JPEG) and allow only the types you serve.
  • Store uploads outside the web root, or in object storage, and serve them with Content-Disposition: attachment, so an uploaded HTML or SVG file cannot run script on your origin.
  • In containers with a read-only root filesystem, buffering needs a writable temp directory: mount one and point ASPNETCORE_TEMP at it, or uploads over 64 KB fail.

Example

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

When to use it

  • Accept a PDF attachment on POST /invoices/{id}/attachments, reject renamed executables by their first bytes and store the file under a GUID name.
  • Stream a 2 GB order import to storage with MultipartReader instead of buffering it to the container's temp disk.
  • Keep antiforgery on for a cookie-authenticated returns form while calling DisableAntiforgery on the bearer-token upload API.

More examples

Several files plus form fields

<code>[FromForm] ReturnRequest</code> binds the text fields and <code>IFormFileCollection</code> receives every file part, so one request carries both. Because this endpoint is used from a browser with cookie authentication, it keeps antiforgery on and adds the middleware explicitly; the page must render the token from <code>IAntiforgery.GetAndStoreTokens</code> into a hidden <code>__RequestVerificationToken</code> field. The content type check here is a first filter only; verify the leading bytes before storing, as the main example does.

Example · csharp
using Microsoft.AspNetCore.Mvc;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAuthentication().AddCookie();
builder.Services.AddAuthorization();
builder.Services.AddAntiforgery();
var app = builder.Build();

app.UseAuthentication();
app.UseAuthorization();
app.UseAntiforgery(); // after authentication and authorization

// Browser form with cookie auth: the antiforgery token field must be posted with the files.
app.MapPost("/returns/{orderId:int}/photos", async (int orderId, [FromForm] ReturnRequest request, IFormFileCollection photos, CancellationToken ct) =>
{
    if (photos.Count is 0 or > 5)
        return Results.Problem(statusCode: 400, title: "Attach between 1 and 5 photos.");

    var saved = new List<string>();
    foreach (var photo in photos)
    {
        if (photo.Length > 5 * 1024 * 1024 || photo.ContentType is not ("image/jpeg" or "image/png"))
            return Results.Problem(statusCode: 400, title: "Photos must be JPEG or PNG and at most 5 MB.");
        saved.Add($"{Guid.NewGuid():N}{(photo.ContentType == "image/png" ? ".png" : ".jpg")}");
    }
    return Results.Ok(new { orderId, request.Reason, photos = saved });
}).RequireAuthorization();

app.Run();

public sealed class ReturnRequest
{
    public string Reason { get; set; } = "";
    public bool Damaged { get; set; }
}

Streaming a large upload with MultipartReader

Binding <code>HttpRequest</code> instead of <code>IFormFile</code> means the framework does not read or buffer the form; disk use then depends entirely on where you copy the bytes. This version writes one local file, so the <code>finally</code> block deletes the partial file on a limit breach, a cancelled request or any exception, and a second file section is rejected instead of overwriting the first. The counter inside the copy loop is the real size check, raising <code>MaxRequestBodySize</code> must happen before the body is first read, and an endpoint that accepts 2 GB must never be anonymous.

Example · csharp
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Net.Http.Headers;

const long MaxImportBytes = 2L * 1024 * 1024 * 1024; // 2 GB

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAuthentication().AddJwtBearer();
builder.Services.AddAuthorization();
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();

app.MapPost("/imports/orders", async (HttpRequest request, CancellationToken ct) =>
{
    if (!MediaTypeHeaderValue.TryParse(request.ContentType, out var mediaType) || mediaType.Boundary.Length == 0)
        return Results.Problem(statusCode: 415, title: "Expected multipart/form-data.");

    // Per-endpoint body limit; Kestrel's global 30 MB default stays in place elsewhere.
    request.HttpContext.Features.Get<Microsoft.AspNetCore.Http.Features.IHttpMaxRequestBodySizeFeature>()!.MaxRequestBodySize = MaxImportBytes;

    var reader = new MultipartReader(HeaderUtilities.RemoveQuotes(mediaType.Boundary).Value!, request.Body);
    var importId = Guid.NewGuid().ToString("N");
    // Local disk keeps the example self-contained; production streams to blob or S3 storage instead.
    var target = Path.Combine(Path.GetTempPath(), $"orders-import-{importId}.csv");
    long total = 0;
    var files = 0;
    var completed = false;

    try
    {
        while (await reader.ReadNextSectionAsync(ct) is { } section)
        {
            if (!ContentDispositionHeaderValue.TryParse(section.ContentDisposition, out var disposition) || !disposition.IsFileDisposition())
                continue;
            if (++files > 1)
                return Results.Problem(statusCode: 400, title: "Send exactly one import file.");

            await using var output = File.Create(target);
            var buffer = new byte[81920];
            int read;
            while ((read = await section.Body.ReadAsync(buffer, ct)) > 0)
            {
                total += read;
                if (total > MaxImportBytes) return Results.Problem(statusCode: 413, title: "Import too large.");
                await output.WriteAsync(buffer.AsMemory(0, read), ct);
            }
        }

        if (files == 0) return Results.Problem(statusCode: 400, title: "Send exactly one import file.");
        completed = true;
        return Results.Accepted($"/imports/orders/{importId}", new { bytes = total });
    }
    finally
    {
        // A limit breach, a disconnect or an exception must not leave up to 2 GB behind in temp.
        if (!completed) File.Delete(target);
    }
}).RequireAuthorization();

app.Run();

Kestrel and form limits in configuration

The default builders do not bind <code>Kestrel:Limits</code>: without <code>builder.Services.Configure&lt;KestrelServerOptions&gt;(builder.Configuration.GetSection("Kestrel"))</code> in Program.cs this block is silently ignored (see net-key-kestrel-limits). With that one line the global request body ceiling can differ per environment without further code changes. Upload-specific values belong in your own options class, bound and validated at start-up, and used by the endpoint for both <code>RequestSizeLimitAttribute</code> and the <code>file.Length</code> check. Remember the proxy in front: an nginx ingress with its default 1 MB <code>client_max_body_size</code> rejects a 2 MB PDF long before these settings matter.

Example · json
{
  // Kestrel:Limits is ignored unless Program.cs binds it:
  // builder.Services.Configure<KestrelServerOptions>(builder.Configuration.GetSection("Kestrel"));
  "Kestrel": {
    "Limits": {
      // Global ceiling for every request body (default 30000000 bytes).
      // Keep it modest; raise it per endpoint with RequestSizeLimitAttribute.
      "MaxRequestBodySize": 31457280
    }
  },
  "Uploads": {
    "MaxInvoicePdfBytes": 10485760,
    "AllowedInvoiceTypes": [ "application/pdf" ]
  }
}

Discussion

  • Be the first to comment on this lesson.