| name | minimal-api-file-upload |
| description | File upload endpoints in ASP.NET minimal APIs (.NET 8+) |
| license | MIT |
Implementing File Uploads in ASP.NET Core Minimal APIs
When to Use
- File upload endpoints in ASP.NET Core minimal APIs (.NET 8+)
- Handling IFormFile or IFormFileCollection parameters
- When you need size limits, content type validation, or streaming large files
When Not to Use
- MVC controllers →
[FromForm] IFormFile works directly with attributes
- Simple JSON body → no file upload needed
- Very large files (> 1GB) → use streaming with
MultipartReader instead
Inputs
| Input | Required | Description |
|---|
| File parameter(s) | Yes | IFormFile or IFormFileCollection |
| Size limits | Yes | Max file/request size |
| Allowed types | No | Content type or extension restrictions |
Workflow
Step 1: CRITICAL — Understand IFormFile Binding in Minimal APIs
app.MapPost("/upload", (IFormFile file) => ...);
app.MapPost("/upload-with-metadata",
([FromForm] IFormFile file, [FromForm] string description) =>
{
return Results.Ok(new { file.FileName, Description = description });
});
app.MapPost("/upload-multiple", (IFormFileCollection files) =>
{
return Results.Ok(files.Select(f => new { f.FileName, f.Length }));
});
Step 2: CRITICAL — File Size Limits Are Separate from Request Size Limits
builder.WebHost.ConfigureKestrel(options =>
{
options.Limits.MaxRequestBodySize = 10 * 1024 * 1024;
});
builder.Services.Configure<FormOptions>(options =>
{
options.MultipartBodyLengthLimit = 10 * 1024 * 1024;
options.ValueLengthLimit = 1024 * 1024;
options.MultipartHeadersLengthLimit = 16384;
});
app.MapPost("/upload-large", [RequestSizeLimit(200_000_000)] (IFormFile file) =>
{
return Results.Ok(new { file.FileName, file.Length });
});
app.MapPost("/upload-unlimited", [DisableRequestSizeLimit] async (HttpContext context) =>
{
});
Step 3: CRITICAL — Anti-Forgery Auto-Validates Form Uploads in .NET 8+
builder.Services.AddAntiforgery();
var app = builder.Build();
app.UseAntiforgery();
app.MapPost("/upload", (IFormFile file) => Results.Ok(file.FileName));
app.MapPost("/api/upload", (IFormFile file) => Results.Ok(file.FileName))
.DisableAntiforgery();
Step 4: CRITICAL — Validate File Content, Not Just Extension
app.MapPost("/upload", async (IFormFile file) =>
{
var allowedTypes = new[] { "image/jpeg", "image/png" };
if (!allowedTypes.Contains(file.ContentType, StringComparer.OrdinalIgnoreCase))
return Results.BadRequest("File type not allowed");
using var stream = file.OpenReadStream();
var header = new byte[8];
var bytesRead = await stream.ReadAsync(header, 0, header.Length);
if (bytesRead < 4)
return Results.BadRequest("File content is too short or invalid");
var isJpeg = header[0] == 0xFF && header[1] == 0xD8 && header[2] == 0xFF;
var isPng = header[0] == 0x89 && header[1] == 0x50 && header[2] == && header[] == ;
? detectedContentType = isJpeg ? : isPng ? : ;
(detectedContentType )
Results.BadRequest();
(!.Equals(.ContentType, detectedContentType, StringComparison.OrdinalIgnoreCase))
Results.BadRequest();
extension = detectedContentType == ? : ;
safeFileName = ;
filePath = Path.Combine(, safeFileName);
Directory.CreateDirectory();
stream.Position = ;
fileStream = File.Create(filePath);
stream.CopyToAsync(fileStream);
Results.Ok( { FileName = safeFileName, .Length });
});
Step 5: CRITICAL — Streaming Large Files Without Buffering
app.MapPost("/upload-stream",
[DisableRequestSizeLimit]
async (HttpContext context) =>
{
var contentType = context.Request.ContentType;
if (contentType == null)
return Results.BadRequest("Missing Content-Type");
if (!MediaTypeHeaderValue.TryParse(contentType, out var mediaType))
return Results.BadRequest("Invalid Content-Type");
var boundary = HeaderUtilities.RemoveQuotes(mediaType.Boundary).Value;
if (string.IsNullOrWhiteSpace(boundary))
return Results.BadRequest("Not a multipart request");
var reader = new MultipartReader(boundary, context.Request.Body);
while (await reader.ReadNextSectionAsync() is { } section)
{
if (!ContentDispositionHeaderValue.TryParse(section.ContentDisposition, out var contentDisposition))
;
(contentDisposition.DispositionType.Equals()
&& !.IsNullOrEmpty(contentDisposition.FileName.Value))
{
originalFileName = contentDisposition.FileName.Value ?? .Empty;
sanitizedFileName = Path.GetFileName(originalFileName.Trim());
safeFile = ;
Directory.CreateDirectory();
fileStream = File.Create(Path.Combine(, safeFile));
section.Body.CopyToAsync(fileStream);
}
}
Results.Ok();
}).DisableAntiforgery();
Common Mistakes
- Only configuring one size limit: Must configure BOTH Kestrel
MaxRequestBodySize AND FormOptions.MultipartBodyLengthLimit.
- 400 errors from anti-forgery: In .NET 8+,
UseAntiforgery() auto-validates form uploads. Use .DisableAntiforgery() for API endpoints (safe for JWT/unauthenticated; do NOT disable for cookie-authenticated endpoints).
- Trusting file.FileName: User-provided filename can contain path traversal. Generate a safe filename with
Guid.NewGuid() and derive the extension from validated content.
- Trusting Content-Type only: Content type is client-spoofable. Always check magic bytes for actual file type verification.
- Using IFormFile for very large files: Multipart form parsing buffers with a memory threshold and spills to temp files. Use
MultipartReader to stream data in chunks directly to storage without buffering the entire file.
- Deriving file extension from user input: Prefer deriving the extension from the validated content type or magic bytes rather than
Path.GetExtension(file.FileName). If the original extension must be preserved, validate it against the detected content type.