← All writing
articleApr 04, 202513 min read

File Uploads with Swagger and Minimal APIs

Designing a .NET upload boundary that is honest about buffering, quotas, untrusted metadata, scanning, durable storage, retries, and OpenAPI.

.NETC#API
File Uploads with Swagger and Minimal APIs cover illustration

An upload endpoint can fit in ten lines and still own four failure domains: the HTTP body, temporary buffering, durable object storage, and whatever decides that the object is safe to use.

That is why I do not start this design with Swagger’s file chooser. Swagger proves that one client can construct a multipart request. It does not prove the server rejects oversized bodies early, survives a disconnected client, avoids unsafe filenames, or prevents an unscanned object from being downloaded.

The first decision is the transfer path. Small profile images, 50 MB documents, and 8 GB media files should not inherit one endpoint because they all contain bytes.

Three different upload paths

Path Best fit Application owns
Buffered multipart with IFormFile Small, bounded files and simple forms Parsing, temporary buffering, validation, storage copy
Unbuffered multipart with MultipartReader Larger multipart bodies that must pass through the API Section parsing, streaming limits, partial cleanup, backpressure
Direct upload to object storage Large files or high aggregate bandwidth Authorization, short-lived upload grant, completion verification, processing state

IFormFile is the simplest contract, but it is a buffered model. ASP.NET Core buffers multipart files in memory up to configured thresholds and then on disk. Calling OpenReadStream() avoids copying the entire file into another byte array; it does not undo the buffering that already occurred while the form was read.

That distinction changes capacity planning. Fifty concurrent 200 MB requests are not merely fifty asynchronous streams. They can become significant temporary-disk use, open-file pressure, request duration, and cleanup work.

A bounded IFormFile endpoint

For a genuinely small document, keep the contract explicit:

public sealed record UploadDocumentResponse(
    Guid DocumentId,
    string Status,
    long Length,
    string Sha256);

app.MapPost("/documents", async Task<IResult> (
    IFormFile file,
    DocumentIngestion ingestion,
    CancellationToken cancellationToken) =>
{
    const long maximumLength = 20 * 1024 * 1024;

    if (file.Length is <= 0 or > maximumLength)
    {
        return Results.Problem(
            statusCode: StatusCodes.Status413PayloadTooLarge,
            title: "The file size is outside the accepted range.",
            extensions: new Dictionary<string, object?>
            {
                ["code"] = "document.invalid_size",
                ["maximumBytes"] = maximumLength
            });
    }

    await using var content = file.OpenReadStream(maximumLength);
    var accepted = await ingestion.AcceptAsync(
        originalFileName: file.FileName,
        declaredContentType: file.ContentType,
        content,
        cancellationToken);

    return Results.Accepted(
        $"/documents/{accepted.DocumentId}",
        new UploadDocumentResponse(
            accepted.DocumentId,
            "pending-scan",
            accepted.Length,
            accepted.Sha256));
})
.Accepts<IFormFile>("multipart/form-data")
.Produces<UploadDocumentResponse>(StatusCodes.Status202Accepted)
.ProducesProblem(StatusCodes.Status413PayloadTooLarge)
.RequireAuthorization();

The response is 202 Accepted, not 201 Created, because persistence of bytes is not the same as publication of a usable document. The ingestion service creates an application-generated object key, copies with a hard byte limit, calculates the digest while copying, and records the object as pending-scan.

The cancellation token must reach the storage client. If the browser disconnects, continuing an unnecessary multi-megabyte copy consumes capacity and may leave an orphan. The storage layer still needs deterministic cleanup because cancellation is cooperative: the client can disappear after the object is written but before the metadata transaction commits.

Limits have to agree across the path

A body may be accepted or rejected by several layers before endpoint code executes:

client -> CDN/WAF -> reverse proxy -> Kestrel -> multipart parser -> application -> object store

Set an intentional maximum at each accepting layer. The externally documented limit should not exceed a lower hidden proxy or server limit. Otherwise clients see a proxy-generated error with a different body, and application telemetry never records the attempt.

There are two checks with different purposes:

  • the request/body limit stops excessive transfer and buffering as early as possible;
  • the application limit enforces the business contract and returns the stable error code.

Do not trust only IFormFile.Length. When streaming manually, count actual bytes and stop after the allowed maximum. A declared length is metadata, not a substitute for enforcing the stream boundary.

Capacity also includes temporary storage. Monitor free space and rejected requests. If the multipart buffering directory fills, unrelated uploads can fail even when durable object storage is healthy.

The filename is display metadata

file.FileName and file.ContentType came from the caller. Neither is a storage key and neither proves what the bytes contain.

For every accepted file:

  • generate a random or otherwise application-owned object key;
  • strip any path before retaining a display name;
  • HTML-encode the display name when presenting it;
  • enforce an extension allowlist where the product requires one;
  • inspect the file signature or parse the expected format;
  • store in a location that cannot execute uploaded content;
  • return downloads with controlled Content-Type and Content-Disposition headers;
  • scan before making the object available.

Renaming invoice.exe to invoice.pdf changes only a string. A signature check is stronger than the extension, but even a structurally valid format can carry malicious content. Format validation and malware scanning answer different questions.

Archive formats need their own policy. A small compressed upload can expand into enormous storage or contain nested archives and path traversal entries. Put limits on expanded bytes, file count, nesting, and processing time before extraction.

Model scanning as state, not a boolean afterthought

I use an explicit lifecycle:

initiated -> uploading -> pending-scan -> available
                              |              |
                              v              v
                        rejected        quarantined
                              |
                              v
                            failed

Only available objects are downloadable through the normal product path. pending-scan is not a temporary synonym for available. A scanner timeout leaves the object pending or failed according to policy; it does not silently publish it.

The metadata record should contain at least:

  • application document ID and storage key;
  • owner or tenant;
  • expected and observed length;
  • a cryptographic digest;
  • declared and detected content type;
  • current state and state timestamps;
  • scanner engine/signature version when available;
  • rejection reason safe to expose to the caller.

If the storage write and database insert cannot share a transaction, design reconciliation. A periodic job can find old objects with no metadata and metadata records whose objects do not exist. Give both directions an age threshold so it does not race live uploads.

Retries need an identity

Mobile clients retry after timeouts. A timeout after the server stored all bytes leaves the client unable to tell success from failure.

For API-proxied uploads, accept an idempotency key scoped to the authenticated owner and operation. Record the request identity with the resulting document ID. Repeating the same key and same request returns the existing result; reusing it with different content metadata is a conflict.

The file digest helps detect content equality, but it is not automatically a global deduplication key. Two tenants can upload the same document and still require separate authorization, retention, audit, and deletion records. Physical deduplication is a storage decision underneath those logical records.

Large uploads should usually bypass the application process

When files are large or upload traffic is high, the API often should authorize the transfer rather than proxy it.

The sequence becomes:

  1. Client requests an upload session with filename, length, and intended type.
  2. API authorizes the user, creates an initiated record, and returns a short-lived, narrowly scoped upload URL.
  3. Client uploads directly to object storage, using multipart or resumable upload when supported.
  4. Client calls the completion endpoint with the upload ID.
  5. API verifies object existence, size, storage metadata, and optionally digest.
  6. API moves the record to pending-scan and emits processing work through an outbox.
  7. Scanner or processor publishes available or rejected.

The completion request is a claim, not proof. Verify the object through a server-side storage call. Bind the grant to one object key, operation, size range where supported, and a short expiry. Do not hand the browser a credential that can list or overwrite a container.

This path removes application bandwidth from the hot transfer, but it adds abandoned sessions and multipart parts. Configure lifecycle cleanup and reconcile uploads that never complete.

Antiforgery depends on how credentials travel

Disabling antiforgery because Swagger fails is not a design decision.

If a browser automatically attaches a cookie credential, a hostile site may be able to cause a cross-site request with that credential. Antiforgery protection is part of the defense for unsafe methods in that model. A bearer token explicitly attached by a non-browser client has a different exposure, but CORS is not a replacement for CSRF protection.

In current ASP.NET Core, Minimal API endpoints that bind form data participate in antiforgery behavior when the services and middleware are configured. Decide from the authentication and client model, configure the middleware in the documented order, and make interactive API documentation acquire the required token if it is intended to test cookie-authenticated writes.

OpenAPI is part of the contract, not the runtime proof

The generated operation should describe:

  • multipart/form-data with the correct binary property;
  • required metadata fields;
  • maximum documented size and allowed formats;
  • 202 processing semantics and the status resource;
  • stable problem responses such as document.invalid_size;
  • idempotency header behavior;
  • authentication and antiforgery requirements.

Inspect the generated OpenAPI document in a test. UI behavior changes across framework and tooling versions, and a pretty file chooser can coexist with a missing error schema or incorrect field name.

Failure tests before production

The useful test plan is larger than “upload a PDF in Swagger.”

Contract and security

  • empty, exactly-at-limit, and one-byte-over-limit bodies;
  • mismatched extension, declared MIME type, and file signature;
  • path components and control characters in the filename;
  • unauthenticated and wrong-tenant completion/download requests;
  • duplicate idempotency key with same and different metadata;
  • archive expansion and file-count limits.

Transfer and storage

  • client disconnect during body buffering and during storage copy;
  • proxy limit lower than the application’s configured limit;
  • temporary disk nearly full;
  • object store timeout before and after the durable write;
  • database failure after object creation;
  • duplicate completion events and scanner callbacks.

Processing and recovery

  • scanner unavailable for longer than the retry window;
  • malicious result after an earlier pending response;
  • object deleted before scanning;
  • orphan cleanup without touching active uploads;
  • quarantine access restricted to the operational role.

Track accepted and rejected bytes, active transfers, transfer duration, temporary-disk use, scan queue age, scanner outcome, orphan count, and time from initiation to availability. HTTP request count alone will not show a processing backlog.

The boundary

For a small bounded file, IFormFile is a productive abstraction—as long as the design admits that it buffers. For large files, stream deliberately or move the transfer to object storage. In every path, the public contract is not “we received some bytes.” It is “we know whose bytes these are, what limits applied, where they are, whether they are safe, and how an interrupted attempt recovers.”

Making Swagger display a file input was one task. Building an upload boundary that remains safe when storage, clients, and scanners fail was the feature.

Technical references

Keep reading
Browse everything