| name | minimal-api-file-upload |
| description | File upload endpoints in ASP.NET minimal APIs (.NET 8+) |
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] == 0x4E && header[3] == 0x47;
string? detectedContentType = isJpeg ? "image/jpeg" : isPng ? "image/png" : null;
if (detectedContentType is null)
return Results.BadRequest("File content is not a supported image format (only JPEG and PNG are allowed).");
if (!string.Equals(file.ContentType, detectedContentType, StringComparison.OrdinalIgnoreCase))
return Results.BadRequest("File content type does not match the declared ContentType header.");
var extension = detectedContentType == "image/jpeg" ? ".jpg" : ".png";
var safeFileName = $"{Guid.NewGuid()}{extension}";
var filePath = Path.Combine("uploads", safeFileName);
Directory.CreateDirectory("uploads");
stream.Position = 0;
using var fileStream = File.Create(filePath);
await stream.CopyToAsync(fileStream);
return Results.Ok(new { FileName = safeFileName, file.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))
continue;
if (contentDisposition.DispositionType.Equals("form-data")
&& !string.IsNullOrEmpty(contentDisposition.FileName.Value))
{
var originalFileName = contentDisposition.FileName.Value ?? string.Empty;
var sanitizedFileName = Path.GetFileName(originalFileName.Trim('"'));
var safeFile = $"{Guid.NewGuid()}";
Directory.CreateDirectory("uploads");
using var fileStream = File.Create(Path.Combine("uploads", safeFile));
await section.Body.CopyToAsync(fileStream);
}
}
return Results.Ok("Uploaded");
}).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.