$npx -y skills add managedcode/dotnet-skills --skill minimal-api-file-uploadFile upload endpoints in ASP.NET minimal APIs (.NET 8+)
| 1 | # Implementing File Uploads in ASP.NET Core Minimal APIs |
| 2 | |
| 3 | ## When to Use |
| 4 | - File upload endpoints in ASP.NET Core minimal APIs (.NET 8+) |
| 5 | - Handling IFormFile or IFormFileCollection parameters |
| 6 | - When you need size limits, content type validation, or streaming large files |
| 7 | |
| 8 | ## When Not to Use |
| 9 | - MVC controllers → `[FromForm] IFormFile` works directly with attributes |
| 10 | - Simple JSON body → no file upload needed |
| 11 | - Very large files (> 1GB) → use streaming with `MultipartReader` instead |
| 12 | |
| 13 | ## Inputs |
| 14 | |
| 15 | | Input | Required | Description | |
| 16 | |-------|----------|-------------| |
| 17 | | File parameter(s) | Yes | IFormFile or IFormFileCollection | |
| 18 | | Size limits | Yes | Max file/request size | |
| 19 | | Allowed types | No | Content type or extension restrictions | |
| 20 | |
| 21 | ## Workflow |
| 22 | |
| 23 | ### Step 1: CRITICAL — Understand IFormFile Binding in Minimal APIs |
| 24 | |
| 25 | ```csharp |
| 26 | // In .NET 8+ minimal APIs, IFormFile binds automatically from multipart/form-data |
| 27 | // when it is the only complex parameter. |
| 28 | app.MapPost("/upload", (IFormFile file) => ...); |
| 29 | |
| 30 | // CRITICAL: When you mix files with other form fields, use [FromForm] on all |
| 31 | // form-bound parameters (or group them into a single [FromForm] DTO). |
| 32 | app.MapPost("/upload-with-metadata", |
| 33 | ([FromForm] IFormFile file, [FromForm] string description) => |
| 34 | { |
| 35 | return Results.Ok(new { file.FileName, Description = description }); |
| 36 | }); |
| 37 | |
| 38 | // Multiple files: IFormFileCollection also binds automatically from multipart/form-data. |
| 39 | // You only need [FromForm] if you mix it with other form fields, as shown above. |
| 40 | app.MapPost("/upload-multiple", (IFormFileCollection files) => |
| 41 | { |
| 42 | return Results.Ok(files.Select(f => new { f.FileName, f.Length })); |
| 43 | }); |
| 44 | ``` |
| 45 | |
| 46 | ### Step 2: CRITICAL — File Size Limits Are Separate from Request Size Limits |
| 47 | |
| 48 | ```csharp |
| 49 | // CRITICAL: There are TWO different size limits and you need to configure BOTH |
| 50 | |
| 51 | // 1. Request body size limit (Kestrel level) — default is 30MB |
| 52 | builder.WebHost.ConfigureKestrel(options => |
| 53 | { |
| 54 | options.Limits.MaxRequestBodySize = 10 * 1024 * 1024; // 10 MB |
| 55 | }); |
| 56 | |
| 57 | // 2. Form options — multipart body length limit — default is 128MB |
| 58 | builder.Services.Configure<FormOptions>(options => |
| 59 | { |
| 60 | options.MultipartBodyLengthLimit = 10 * 1024 * 1024; // 10 MB |
| 61 | options.ValueLengthLimit = 1024 * 1024; // 1 MB for form values |
| 62 | options.MultipartHeadersLengthLimit = 16384; // 16 KB for section headers |
| 63 | }); |
| 64 | |
| 65 | // COMMON MISTAKE: Only increasing Kestrel MaxRequestBodySize |
| 66 | // upload still fails because FormOptions.MultipartBodyLengthLimit is exceeded |
| 67 | |
| 68 | // COMMON MISTAKE: Only increasing FormOptions |
| 69 | // upload fails with "Request body too large" from Kestrel before reaching form parsing |
| 70 | |
| 71 | // CRITICAL: Per-endpoint override with RequestSizeLimit attribute |
| 72 | app.MapPost("/upload-large", [RequestSizeLimit(200_000_000)] (IFormFile file) => |
| 73 | { |
| 74 | return Results.Ok(new { file.FileName, file.Length }); |
| 75 | }); |
| 76 | |
| 77 | // CRITICAL: To disable the limit entirely (for streaming): |
| 78 | app.MapPost("/upload-unlimited", [DisableRequestSizeLimit] async (HttpContext context) => |
| 79 | { |
| 80 | // Handle manually |
| 81 | }); |
| 82 | ``` |
| 83 | |
| 84 | ### Step 3: CRITICAL — Anti-Forgery Auto-Validates Form Uploads in .NET 8+ |
| 85 | |
| 86 | ```csharp |
| 87 | // CRITICAL: In .NET 8+ with UseAntiforgery(), ALL form-bound endpoints |
| 88 | // automatically validate anti-forgery tokens, INCLUDING file uploads |
| 89 | |
| 90 | builder.Services.AddAntiforgery(); |
| 91 | var app = builder.Build(); |
| 92 | app.UseAntiforgery(); |
| 93 | |
| 94 | // This endpoint now REQUIRES an anti-forgery token: |
| 95 | app.MapPost("/upload", (IFormFile file) => Results.Ok(file.FileName)); |
| 96 | // Without the token → 400 Bad Request |
| 97 | |
| 98 | // CRITICAL: For API-only file uploads (no anti-forgery needed), opt out: |
| 99 | app.MapPost("/api/upload", (IFormFile file) => Results.Ok(file.FileName)) |
| 100 | .DisableAntiforgery(); // CRITICAL: Must explicitly opt out |
| 101 | |
| 102 | // COMMON MISTAKE: Getting 400 errors on file uploads and not realizing |
| 103 | // it's because UseAntiforgery() is in the pipeline |
| 104 | |
| 105 | // WARNING: DisableAntiforgery() is safe for unauthenticated endpoints and |
| 106 | // endpoints using JWT bearer authentication. However, for endpoints |
| 107 | // authenticated with cookies, disabling antiforgery removes CSRF protection |
| 108 | // and exposes the endpoint to cross-site request forgery attacks. |
| 109 | // For cookie-authenticated endpoints, include a valid antiforgery token instead. |
| 110 | ``` |
| 111 | |
| 112 | ### Step 4: CRITICAL — Validate File Content, Not Just Extension |
| 113 | |
| 114 | ```csharp |
| 115 | app.MapPost("/upload", async (IFormFile file) => |
| 116 | { |
| 117 | // CRITICAL: Check content type AND file signature (magic bytes) |
| 118 | // NEVER trust file extension alone — it can be spoofed |
| 119 | |
| 120 | // Allow only JPEG/PNG by default. To support more (e.g., GIF), |
| 121 | // add the MIME type here AND validate its magic bytes below. |
| 122 | var allowedTypes = new[] { "image/jpeg", "image/png" }; |
| 123 | if (!allowedTypes.Contains(file.ContentType, StringComparer.OrdinalIgnoreCase)) |
| 124 | return Results.BadRequest("File type not allowed"); |
| 125 | |
| 126 | // CRITICAL: Check magic bytes for file type verification |
| 127 | using |