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

File Upload and Download in ASP.NET Core Web API: IFormFile, Limits and Streaming

Mert Özen Nov 4, 2026 10 min 2 views
File Upload and Download in ASP.NET Core Web API: IFormFile, Limits and Streaming

We upload product images with IFormFile and guard against path traversal and fake extensions. We cover Kestrel and form limits, downloads with PhysicalFile and streaming with MultipartReader.

Version note: The code in this post is written for .NET 6 and runs unchanged on .NET 10.

Guess First

In the previous post we paged, filtered and sorted the product list. The catalog now handles big lists comfortably. But a product is more than a name and a price; it has an image, and maybe a PDF manual too. In this post we talk about receiving files in an API and handing them back.

One question before we start. You send a 40 MB file to the first upload endpoint we are about to write, without changing any settings. What does the server return? 413 Payload Too Large, 500, or does it accept the file with a 200? The answer comes in the section on limits.

A First Upload with IFormFile

Let's start with the plainest version. I am writing this code incomplete on purpose; we will soon see why it is dangerous:

using Microsoft.AspNetCore.Mvc;

namespace ProductCatalog.Controllers;

[ApiController]
[Route("api/products/{productId:int}/image")]
public class ProductImagesController : ControllerBase
{
    private readonly string _storagePath;

    public ProductImagesController(IWebHostEnvironment environment)
    {
        // Files will be written to the uploads folder inside the project
        _storagePath = Path.Combine(environment.ContentRootPath, "uploads");
        Directory.CreateDirectory(_storagePath);
    }

    [HttpPost]
    public async Task<IActionResult> Upload(int productId, IFormFile file)
    {
        // WARNING: we take the file name straight from the client
        var path = Path.Combine(_storagePath, file.FileName);

        await using var stream = System.IO.File.Create(path);
        await file.CopyToAsync(stream);

        return Ok(new { file.FileName, file.Length, file.ContentType });
    }
}

IFormFile is how ASP.NET Core represents a file that arrives with multipart/form-data. The [ApiController] attribute infers [FromForm] as the source for a parameter of this type, so you do not have to write it. The only requirement is that the parameter name matches the form field name: file here.

Notice the line where we wrote System.IO.File.Create. If you write just File.Create inside a controller, the compiler stops you, because ControllerBase has a method named File() and the name resolves to it first:

error CS0119: 'ControllerBase.File(byte[], string)' is a method, which is not valid in the given context

Run the app and send an image with curl:

curl -F "file=@keyboard.png" http://localhost:5080/api/products/1/image
{"fileName":"keyboard.png","length":179,"contentType":"image/png"}

FileName, Length and ContentType look good enough at first glance. If you do not send a file at all, [ApiController] answers with a 400 and the errors object says "file": ["The file field is required."]. Because nullable reference types are enabled, the IFormFile parameter counts as required.

Never Trust the File Name

The server does not produce FileName and ContentType; whatever the client writes is what arrives. Changing the file name with curl takes one parameter:

curl -F "file=@keyboard.png;filename=../hacked.png" http://localhost:5080/api/products/1/image
{"fileName":"../hacked.png","length":179,"contentType":"image/png"}

The response looks innocent, but when you check the disk you see that hacked.png was written not into the uploads folder but one level up, into the project root, right next to appsettings.json. This is called path traversal. The same trick can overwrite another file on the server.

Think of a hotel front desk. A guest cannot say "don't put me in 1205, put me in the manager's room"; the front desk assigns the room number. In the same way, you generate the file name: product-1.png or a Guid. If you want to show the original name the user gave, store it in the database as metadata and never use it in a path.

The second lie is ContentType. Rename a shell script to evil.png, send it, and curl looks at the extension and sends image/png. The way to tell whether the content really is an image is to look at the first bytes of the file. Every format has a signature (file signature, magic number): PNG files start with 89 50 4E 47 0D 0A 1A 0A, JPEG files with FF D8 FF. We will check the extension against an allow list and the content against this signature.

Size Limits: Three Gates in a Row

Now the answer to the opening question. When you send 40 MB to the first endpoint, you get a 400, not a 413:

{
  "title": "One or more validation errors occurred.",
  "status": 400,
  "errors": {
    "": [
      "Failed to read the request form. Request body too large. The max request body size is 30000000 bytes."
    ]
  }
}

(I removed the type and traceId fields.) Kestrel's default request body limit is 30,000,000 bytes, about 28.6 MB. When the limit is exceeded Kestrel throws an exception; but since the form is being read by MVC's model binding layer, the exception is caught and added to ModelState under an empty key, and [ApiController] turns that into the familiar 400. When I sent the same 40 MB to an endpoint that reads Request.Body directly, I got Kestrel's own answer, the 413.

A file passes through three gates before it reaches your action. The first is Kestrel's MaxRequestBodySize limit (30,000,000 bytes). The second is the form reader's FormOptions.MultipartBodyLengthLimit; its default is 134,217,728 bytes, that is 128 MB. The third is your business rule: 2 MB is plenty for a product image. You can tighten the limits per action with attributes:

    [HttpPost]
    [RequestSizeLimit(3 * 1024 * 1024)]
    [RequestFormLimits(MultipartBodyLengthLimit = 3 * 1024 * 1024)]

For the whole application you set them in Program.cs:

using Microsoft.AspNetCore.Http.Features;

var builder = WebApplication.CreateBuilder(args);

// Kestrel limit for every request body: 10 MB
builder.WebHost.ConfigureKestrel(options =>
{
    options.Limits.MaxRequestBodySize = 10 * 1024 * 1024;
});

// Limit of the multipart form reader: 10 MB
builder.Services.Configure<FormOptions>(options =>
{
    options.MultipartBodyLengthLimit = 10 * 1024 * 1024;
});

builder.Services.AddControllers();

var app = builder.Build();

app.MapControllers();

app.Run();

The attribute on the action overrides the global setting. Even with the global limit at 10 MB, an action carrying [RequestSizeLimit(200 * 1024 * 1024)] accepted a 40 MB file. Notice that we kept the request limit at 3 MB and the file limit at 2 MB: a multipart body carries boundary lines and headers next to the file, so the request is always a bit larger than the file.

A Safe Upload Action

Put the three gates and the two security checks together and the action looks like this:

using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.StaticFiles;

namespace ProductCatalog.Controllers;

[ApiController]
[Route("api/products/{productId:int}/image")]
public class ProductImagesController : ControllerBase
{
    private const long MaxFileSize = 2 * 1024 * 1024;

    // Allowed extensions and the signature bytes the file must start with
    private static readonly Dictionary<string, byte[]> Signatures = new()
    {
        [".png"] = new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A },
        [".jpg"] = new byte[] { 0xFF, 0xD8, 0xFF },
        [".jpeg"] = new byte[] { 0xFF, 0xD8, 0xFF }
    };

    private readonly string _storagePath;

    public ProductImagesController(IWebHostEnvironment environment)
    {
        _storagePath = Path.Combine(environment.ContentRootPath, "uploads");
        Directory.CreateDirectory(_storagePath);
    }

    [HttpPost]
    [RequestSizeLimit(3 * 1024 * 1024)]
    [RequestFormLimits(MultipartBodyLengthLimit = 3 * 1024 * 1024)]
    public async Task<IActionResult> Upload(int productId, IFormFile file, CancellationToken cancellationToken)
    {
        if (file.Length == 0 || file.Length > MaxFileSize)
        {
            return BadRequest("File size must be between 1 byte and 2 MB.");
        }

        var extension = Path.GetExtension(file.FileName).ToLowerInvariant();
        if (!Signatures.TryGetValue(extension, out var signature))
        {
            return BadRequest("Only .png, .jpg and .jpeg files are allowed.");
        }

        if (!await HasSignatureAsync(file, signature, cancellationToken))
        {
            return BadRequest("File content does not match its extension.");
        }

        // If the product already has an image (maybe with another extension), delete it first
        var existing = FindImage(productId);
        if (existing is not null)
        {
            System.IO.File.Delete(existing);
        }

        // The server generates the file name, not the client
        var fileName = $"product-{productId}{extension}";
        var path = Path.Combine(_storagePath, fileName);

        await using (var stream = System.IO.File.Create(path))
        {
            await file.CopyToAsync(stream, cancellationToken);
        }

        return CreatedAtAction(nameof(GetImage), new { productId }, new { fileName, file.Length });
    }

We take only the extension from the file name, and it must pass the allow list. The signature check is a small helper method:

    private string? FindImage(int productId) =>
        Directory.EnumerateFiles(_storagePath, $"product-{productId}.*").FirstOrDefault();

    private static async Task<bool> HasSignatureAsync(IFormFile file, byte[] signature, CancellationToken cancellationToken)
    {
        await using var stream = file.OpenReadStream();
        var header = new byte[signature.Length];
        var read = await stream.ReadAsync(header, cancellationToken);
        return read == signature.Length && header.SequenceEqual(signature);
    }
}

ReadAsync does not guarantee that it reads as many bytes as you asked for, so we compare the count as well. ReadExactlyAsync, which reads exactly N bytes, arrived in .NET 7; try to compile it on .NET 6 and you get CS1061. We also pass the CancellationToken to CopyToAsync so the copy does not keep running for nothing if the client drops the connection.

The tests now behave as expected. A normal image returns 201 Created with a Location: http://localhost:5080/api/products/1/image header. The script named evil.png gets File content does not match its extension., and the file with the .sh extension gets Only .png, .jpg and .jpeg files are allowed.. A file sent with the name ../hacked.png is now simply saved as product-1.png.

Handing the File Back: PhysicalFile and File

The other side of upload is download. There are two different needs: showing the image in the browser and making the browser download the file.

    [HttpGet]
    public IActionResult GetImage(int productId)
    {
        var path = FindImage(productId);
        if (path is null)
        {
            return NotFound();
        }

        // Find the MIME type from the extension: .png -> image/png
        var provider = new FileExtensionContentTypeProvider();
        if (!provider.TryGetContentType(path, out var contentType))
        {
            contentType = "application/octet-stream";
        }

        return PhysicalFile(path, contentType, enableRangeProcessing: true);
    }

    [HttpGet("download")]
    public IActionResult DownloadImage(int productId)
    {
        var path = FindImage(productId);
        if (path is null)
        {
            return NotFound();
        }

        // FileStreamResult disposes the stream, so no using here
        var stream = System.IO.File.OpenRead(path);
        return File(stream, "application/octet-stream", Path.GetFileName(path));
    }

PhysicalFile streams a file from disk by its absolute path without loading it into memory. With enableRangeProcessing: true the client can ask for part of the file; video players and resumed downloads rely on this:

curl -i -r 0-7 http://localhost:5080/api/products/1/image
HTTP/1.1 206 Partial Content
Content-Length: 8
Content-Type: image/png
Date: Tue, 03 Nov 2026 21:18:07 GMT
Server: Kestrel
Accept-Ranges: bytes
Content-Range: bytes 0-7/179
Last-Modified: Tue, 03 Nov 2026 21:18:07 GMT

The File(stream, contentType, fileDownloadName) overload adds a Content-Disposition header thanks to its third parameter, and the browser downloads the file instead of displaying it:

HTTP/1.1 200 OK
Content-Length: 179
Content-Type: application/octet-stream
Date: Tue, 03 Nov 2026 21:18:07 GMT
Server: Kestrel
Content-Disposition: attachment; filename=product-1.png; filename*=UTF-8''product-1.png

There is also a File(byte[], ...) overload, but it needs the whole file in memory. It is fine for small content that is already built in memory (say, a CSV generated on the fly); for large files on disk, use a stream or PhysicalFile.

Large Files and Streaming

IFormFile is convenient, but it has a price: the whole file is read before your action runs. The first 64 KB stays in memory (FormOptions.MemoryBufferThreshold defaults to 65,536), the rest is written to a temporary file. For 50 MB PDF manuals that means writing to disk twice on every upload. Instead, we can read the multipart sections as they arrive and pipe them straight into the target file:

using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Net.Http.Headers;
using ProductCatalog.Filters;

namespace ProductCatalog.Controllers;

[ApiController]
[Route("api/products/{productId:int}/manual")]
public class ProductManualsController : ControllerBase
{
    private readonly string _storagePath;

    public ProductManualsController(IWebHostEnvironment environment)
    {
        _storagePath = Path.Combine(environment.ContentRootPath, "uploads");
        Directory.CreateDirectory(_storagePath);
    }

    [HttpPost]
    [RequestSizeLimit(200 * 1024 * 1024)]
    [DisableFormValueModelBinding]
    public async Task<IActionResult> Upload(int productId, CancellationToken cancellationToken)
    {
        if (!MediaTypeHeaderValue.TryParse(Request.ContentType, out var mediaType) ||
            !mediaType.MediaType.Equals("multipart/form-data", StringComparison.OrdinalIgnoreCase))
        {
            return BadRequest("Expected a multipart/form-data request.");
        }

        var boundary = HeaderUtilities.RemoveQuotes(mediaType.Boundary).Value;
        if (string.IsNullOrEmpty(boundary))
        {
            return BadRequest("Missing multipart boundary.");
        }

        // We read the body section by section; nothing is buffered up front
        var reader = new MultipartReader(boundary, Request.Body);
        var section = await reader.ReadNextSectionAsync(cancellationToken);

        while (section is not null)
        {
            if (ContentDispositionHeaderValue.TryParse(section.ContentDisposition, out var disposition) &&
                disposition.IsFileDisposition())
            {
                var path = Path.Combine(_storagePath, $"manual-{productId}.pdf");
                await using var target = System.IO.File.Create(path);
                await section.Body.CopyToAsync(target, cancellationToken);

                return Ok(new { fileName = Path.GetFileName(path), length = target.Length });
            }

            section = await reader.ReadNextSectionAsync(cancellationToken);
        }

        return BadRequest("No file section found.");
    }
}

The first time I ran this code without the [DisableFormValueModelBinding] line, I got this exception:

System.IO.IOException: Unexpected end of Stream, the content may have already been read by another component.

The culprit is productId in the route. When the action has a parameter to bind, MVC prepares the value providers; since the request's content type is a form, FormValueProviderFactory reads the form, which means it reads and consumes the entire body. By the time our MultipartReader gets its turn, there is nothing left to read. The fix is one of the resource filters we saw in the post on filters; because it runs before model binding, it can remove the form-reading value providers from the list:

using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.AspNetCore.Mvc.ModelBinding;

namespace ProductCatalog.Filters;

[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
public class DisableFormValueModelBindingAttribute : Attribute, IResourceFilter
{
    public void OnResourceExecuting(ResourceExecutingContext context)
    {
        // Remove the value providers that would read the form and consume the body
        context.ValueProviderFactories.RemoveType<FormValueProviderFactory>();
        context.ValueProviderFactories.RemoveType<FormFileValueProviderFactory>();
        context.ValueProviderFactories.RemoveType<JQueryFormValueProviderFactory>();
    }

    public void OnResourceExecuted(ResourceExecutedContext context)
    {
    }
}

After adding the filter the 50 MB file went through without trouble: {"fileName":"manual-1.pdf","length":52428800}. On this path the FormOptions limits do not apply, because we never use the form reader; the limit is set by [RequestSizeLimit] and your own checks.

Common Pitfalls

Putting file.FileName into a path. We saw it above; the server generates the name, the original name is only metadata.

Trusting ContentType or the extension on their own. Both are just the client's claim. Check the signature; if you expose uploaded files to other systems, consider a virus scan as well.

Putting uploaded files under wwwroot. If UseStaticFiles is on, an HTML or JavaScript file someone uploaded gets served from your domain. Keep files somewhere not directly exposed, and in production use a separate disk or blob storage.

Loading a large file into a MemoryStream or a byte[]. A few uploads arriving at the same time will exhaust the server's memory. Pipe the file to its destination as a stream.

Raising only [RequestSizeLimit]. When I sent a 130 MB file to an IFormFile action that allows 300 MB, I still got a 400: Failed to read the request form. Multipart body length limit 134217728 exceeded. The second gate, MultipartBodyLengthLimit, was still 128 MB.

A Small Experiment

★☆☆ Add a DELETE action to ProductImagesController: if the image exists, delete it and return 204, otherwise return 404.

★★☆ Add .webp support. WebP files start with RIFF, but the real signature is the four bytes WEBP at offset 8; extend HasSignatureAsync to take an offset and try it with a fake .webp file.

★★★ In the manual endpoint, check while streaming that the first 5 bytes are %PDF-. If the signature does not match, or the client drops the connection halfway, do not leave a half-written file on disk.

One gap stands out right now: anyone who asks can replace a product image. The API does not know who sent the request. In the next post we take on authentication and JWT: we will issue a token that proves who the user is and validate that token on every request.