Skip to content
← All articles
HTTP · Updated 2026-10-07

Learn · HTTP

multipart/form-data file uploads

A file upload from a form is a POST with enctype="multipart/form-data". Without that attribute, a file input submits only its file name. This page shows what the body looks like, then the server-side checks an upload endpoint needs, because almost everything in a multipart request is controlled by the client.

The form

html

<form action="/apply" method="post" enctype="multipart/form-data">
  <input name="email" type="email" required>
  <input name="cv" type="file" accept=".pdf,application/pdf" required>
  <button>Send</button>
</form>

accept only filters the file picker. Users can switch the picker to "all files", and scripts ignore it entirely, so it is a convenience, not a check.

What goes over the wire

The browser picks a boundary string, puts it in the Content-Type header, and uses it to separate the parts:

http

POST /apply HTTP/1.1
Content-Type: multipart/form-data; boundary=----formboundary7MA4YWxk

------formboundary7MA4YWxk
Content-Disposition: form-data; name="email"

ada@example.com
------formboundary7MA4YWxk
Content-Disposition: form-data; name="cv"; filename="cv.pdf"
Content-Type: application/pdf

%PDF-1.7 ...binary bytes...
------formboundary7MA4YWxk--
  • Each delimiter is -- plus the boundary; the last one ends with an extra --.
  • Text parts have no Content-Type of their own. File parts carry the browser's guess.
  • In names and filenames the browser escapes only ", CR and LF (as %22, %0D, %0A). Never use a submitted filename as a path.
  • Parts arrive in form order, and repeated names become separate parts.
  • With fetch(), pass a FormData body and let fetch write this header; a hand-set Content-Type has no boundary and the server cannot parse the body.

The empty file part

An optional file input with nothing selected is still submitted: a part with filename="", type application/octet-stream and no bytes. A server that treats every file part as an upload rejects this as a disallowed type or stores an empty file. Skip parts with an empty filename before counting files or checking types.

Size limits at every hop

Every component in front of your code has its own body limit, and the smallest one wins. nginx, for example, rejects bodies over client_max_body_size (1 MB by default) with a 413 before your app sees the request. Then the multipart parser has limits, then your per-file and per-request rules, then storage quota.

Enforce limits while streaming: stop reading once a file passes the cap instead of buffering the whole body and measuring it afterwards. Answer with 413 Content Too Large and a readable message. On the client, check file.size on change for fast feedback, but only as a courtesy:

js

const MAX = 10 * 1024 * 1024;
const input = document.querySelector('input[name="cv"]');
input.addEventListener("change", () => {
  const big = [...input.files].find((f) => f.size > MAX);
  input.setCustomValidity(big ? big.name + " is larger than 10 MB." : "");
  input.reportValidity();
});

The MIME type is a guess, and attacker-controlled

The Content-Type of a file part comes from the browser, which derives it from the file extension without reading the bytes. A PNG renamed to .txt is sent as text/plain, unknown extensions get an empty type, and MDN notes that client configuration such as Windows registry settings can change the answer even for common types. And a client that is not a browser can send any type it likes.

Server-side, that leaves a short list of checks that actually hold:

  • Allowlist the types and extensions you need instead of blocklisting dangerous ones.
  • Where the type matters, check the file's leading bytes (PDF starts with %PDF-, PNG with the bytes 89 50 4E 47).
  • Store under a generated key (a UUID), never the submitted filename, and outside any web root.
  • Serve uploads with Content-Disposition: attachment and X-Content-Type-Options: nosniff, and never render user-supplied HTML or SVG inline on your own origin.
  • Cap file count, per-file size, total size and stored bytes per account.

How this works with Formward

File uploads are available on the Professional and Business plans and are switched on per form. Limits come from the plan: Professional allows 5 files per submission, 10 MiB per file and 25 MiB per submission with 2 GiB of stored attachments; Business allows 10 files, 25 MiB per file, 25 MiB per submission and 10 GiB of storage. Oversized uploads get a 413 and too many files a 400, both before anything is stored.

The declared type of each file is checked against an allowlist (common images, PDF, plain text and CSV, Microsoft Office and OpenDocument files); anything else gets a 415. Empty file parts are skipped. Files are stored under random keys and, in the dashboard, served as downloads with nosniff; only raster images are shown inline, inside a sandboxed response. See the file uploads docs.

Sources

Point a form at an EU endpoint

Formward receives the POST, filters spam and stores submissions in Sweden. No server to run.

multipart/form-data file uploads explained | Formward