Tutorials

How file uploads work in HTML forms (and how to handle them)

August 17, 202613 min read
How file uploads work in HTML forms

You add a file field to a job application form. One line: <input type="file" name="resume">. You pick a PDF, hit submit, and check what arrived on the server.

The value of resume is the string "resume.pdf". No bytes, no content, only the filename.

Nothing is broken. The form did exactly what its encoding told it to do. File uploads are the one place where HTML forms need you to understand the request they produce, so this guide starts there, then covers sending files with JavaScript, receiving them on a server, and the security checks that keep an upload field from becoming the easiest way into your system.

Why your form sent a filename instead of a file

Every form has an encoding type, set by its enctype attribute. If you don't set one, the browser uses application/x-www-form-urlencoded, the same name=value&name=value format as a URL query string.

That format has no way to carry binary data, so for file inputs the browser sends only the file's name. To send the file itself, the form needs multipart/form-data:

<form action="/api/apply" method="POST" enctype="multipart/form-data">
  <label for="email">Email</label>
  <input id="email" name="email" type="email" required />

  <label for="resume">Resume (PDF, PNG, or JPEG, up to 5 MB)</label>
  <input
    id="resume"
    name="resume"
    type="file"
    accept=".pdf,.png,.jpg,.jpeg"
    required
  />

  <button type="submit">Apply</button>
</form>

Two attributes have to be right: method="POST", because a GET request has no body to put the file in, and enctype="multipart/form-data". Miss either one and you get the filename-only result.

What multipart/form-data actually looks like

It helps to see the request once. A multipart body is split into parts by a boundary, a random string the browser generates and declares in the Content-Type header:

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

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

jane@example.com
------WebKitFormBoundary7MA4YWxk
Content-Disposition: form-data; name="resume"; filename="resume.pdf"
Content-Type: application/pdf

%PDF-1.7 ...binary bytes of the file...
------WebKitFormBoundary7MA4YWxk--

Each part has its own headers. Regular fields carry a name. File parts also carry a filename and a Content-Type, and then the raw bytes.

Notice where those two values come from: the client. The filename is whatever the file was called on the visitor's machine, and the Content-Type is the browser's guess, usually based on the file extension. Anyone sending a request by hand can set both to anything. Keep that in mind; it shapes most of the security section below.

The file input, briefly

A few attributes on <input type="file"> are worth knowing:

  • accept filters the file picker, for example accept=".pdf,image/png". It's a convenience for the visitor, not a security control. Anyone can bypass it.
  • multiple lets the visitor select more than one file. The field is then sent once per file, all with the same name.
  • required works the same as on any other input.

You can also check sizes in the browser before anything is uploaded, which saves the visitor from waiting on a 40 MB upload only to be rejected:

const input = document.querySelector("#resume");
const MAX_BYTES = 5 * 1024 * 1024;

input.addEventListener("change", () => {
  const file = input.files[0];
  input.setCustomValidity(
    file && file.size > MAX_BYTES ? "Please choose a file under 5 MB." : ""
  );
});

setCustomValidity plugs into the browser's built-in validation, so the form won't submit and the message appears next to the field. Like accept, this is for the visitor's benefit only. The server enforces the real limit.

Sending files with fetch()

If you submit with JavaScript instead of a full page load, FormData builds the multipart body for you:

const form = document.querySelector("form");

form.addEventListener("submit", async (event) => {
  event.preventDefault();

  const res = await fetch(form.action, {
    method: "POST",
    body: new FormData(form), // includes the selected file
  });
  // handle res.ok / errors as usual
});

The one mistake to avoid: don't set the Content-Type header yourself. The multipart header has to include the boundary string, and only the browser knows it. If you write headers: { "Content-Type": "multipart/form-data" }, the boundary is missing and the server can't split the body into parts. Leave the header out and the browser fills it in correctly.

For the general pattern of submitting forms with fetch() and showing errors inline, see AJAX form validation: common mistakes and how to fix them.

Handling uploads on the server

The server's job is to parse the multipart body, enforce limits, decide whether to trust the file, and store it somewhere safe. Here's a complete version in Node.js with Express and Multer, the multipart parser maintained under the Express project, plus the file-type package for checking what a file really is:

npm install express multer file-type
// server.js (ES module: "type": "module" in package.json)
import express from "express";
import multer from "multer";
import { fileTypeFromBuffer } from "file-type";
import { randomUUID } from "node:crypto";
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";

const MAX_FILE_BYTES = 5 * 1024 * 1024; // 5 MB
const ALLOWED_TYPES = new Map([
  ["application/pdf", "pdf"],
  ["image/png", "png"],
  ["image/jpeg", "jpg"],
]);
const UPLOAD_DIR = path.resolve("uploads"); // not inside your public/static folder
await mkdir(UPLOAD_DIR, { recursive: true });

const upload = multer({
  storage: multer.memoryStorage(),
  limits: { fileSize: MAX_FILE_BYTES, files: 1, fields: 10, fieldSize: 10_000 },
});

const app = express();

The limits object is the most important part of this file. Without it, Multer accepts files of any size and any number of fields. fileSize caps each file, files caps how many arrive, and fields and fieldSize cap the text parts, so a request can't exhaust your server's memory with a flood of junk fields instead of a large file.

memoryStorage() keeps each file in a buffer, which is fine for a 5 MB cap. For larger files, use Multer's disk storage instead so uploads don't sit in RAM.

Now the route:

// server.js (continued)
app.post("/api/apply", upload.single("resume"), async (req, res) => {
  if (!req.file) {
    return res.status(422).json({ ok: false, error: "Please attach a file." });
  }

  // Check the actual bytes, not the filename or the browser's Content-Type.
  const detected = await fileTypeFromBuffer(req.file.buffer);
  const extension = detected && ALLOWED_TYPES.get(detected.mime);
  if (!extension) {
    return res
      .status(415)
      .json({ ok: false, error: "Only PDF, PNG, or JPEG files." });
  }

  // Never reuse the client's filename on disk. Generate your own.
  const storedName = `${randomUUID()}.${extension}`;
  await writeFile(path.join(UPLOAD_DIR, storedName), req.file.buffer, {
    flag: "wx",
  });

  // Save storedName, req.file.originalname, and req.body.email to your database here.
  res.json({ ok: true });
});

upload.single("resume") accepts exactly one file from the field named resume. A file arriving under any other field name is rejected, which stops clients from slipping extra files into fields you never planned for.

fileTypeFromBuffer reads the file's magic bytes, the signature at the start of most binary formats (%PDF for PDFs, \x89PNG for PNGs). A shell script renamed to resume.pdf and sent with Content-Type: application/pdf still gets rejected, because its bytes don't look like a PDF. One caveat: plain-text formats such as CSV have no signature, so they need different validation.

The stored filename is a random UUID plus an extension you chose from your own allowlist. The visitor's original filename goes in the database as display text only. It never touches a file path, which closes off path traversal tricks like ../../server.js. The wx flag makes the write fail rather than overwrite an existing file.

Finally, the error handler turns Multer's limit errors into useful responses:

// server.js (continued)
app.use((err, req, res, next) => {
  if (err instanceof multer.MulterError) {
    const tooLarge = err.code === "LIMIT_FILE_SIZE";
    return res.status(tooLarge ? 413 : 400).json({
      ok: false,
      error: tooLarge
        ? "That file is larger than 5 MB."
        : "That upload couldn't be processed.",
    });
  }
  console.error(err);
  res
    .status(500)
    .json({ ok: false, error: "Upload failed. Please try again." });
});

const port = Number(process.env.PORT ?? 3000);
app.listen(port, () => console.log(`Listening on http://localhost:${port}`));

LIMIT_FILE_SIZE becomes a 413 Payload Too Large. Too many files, too many fields, or a file in an unexpected field become a 400.

The security checks every upload needs

An upload endpoint accepts arbitrary bytes from strangers and stores them on your infrastructure. Each check below closes a specific hole:

CheckWhat it prevents
Size limits at every layerMemory and disk exhaustion from oversized or endless uploads
Type check on the file's bytesExecutables and scripts disguised with a harmless extension
An allowlist of types, not a blocklistNew or obscure dangerous formats you didn't think to block
Generated storage namesPath traversal and overwriting existing files
Storage outside the web rootUploaded files being executed or served as pages by your server
Safe download headersUploaded HTML or SVG running scripts on your domain
Malware scanningInfected documents reaching whoever opens them

A few of these deserve more detail.

Limits at every layer. Your application's limit is only one of several. Reverse proxies and platforms have their own: nginx rejects request bodies over 1 MB by default (client_max_body_size), and Vercel Functions cap request bodies at 4.5 MB. If a proxy limit is lower than your app's, uploads fail with a 413 before your code runs, and your carefully written error message never appears. Set the limits deliberately at each layer so you know which one answers.

Allowlist, never blocklist. Blocking .exe and .sh misses the formats you didn't think of. Accept only the handful of types your form actually needs.

Be careful with HTML and SVG. An SVG file can contain JavaScript. If you serve uploaded files from your main domain and a browser renders one inline, that script runs with your site's cookies and permissions. Leave these formats out of your allowlist unless you need them. If you do, serve uploads with Content-Disposition: attachment and X-Content-Type-Options: nosniff, ideally from a separate domain.

Scan what people will open. Type checks confirm a file is a real PDF. They don't confirm it's a safe PDF. If files will be opened by your team or forwarded to customers, run them through a malware scanner before anyone can download them.

For the rest of the endpoint (rate limiting, origin checks, logging), Best ways to secure a form endpoint covers the layers that apply to every form, not only upload forms.

When files shouldn't go through your server at all

The setup above routes every byte through your application. That's reasonable for small files on a server you control, but it gets uncomfortable as files grow: each upload ties up a connection and memory for its full duration, slow mobile uploads hit request timeouts, and serverless platforms reject large bodies outright.

The common alternative is direct-to-storage uploads. Your server never receives the file. Instead:

  1. The browser asks your server for permission to upload a specific file (name, type, size).
  2. Your server validates that request and returns a short-lived, signed upload policy for object storage such as S3.
  3. The browser uploads the file directly to storage using that policy. The policy itself enforces a size limit, so storage rejects anything larger.
  4. The browser submits the form with a reference to the uploaded file, and your server verifies the stored object before accepting the submission.

Your server only handles small JSON requests, and the heavy transfer happens between the browser and storage. The tradeoff is more moving parts: a signing endpoint, cleanup for files that were uploaded but never submitted, and verification that the stored object matches what was promised.

File uploads with Formtorch

If you'd rather not build that pipeline, Formtorch uses the same direct-to-storage pattern for its file uploads. Uploads are available on the Starter and Pro plans and are turned on per form, under the form's Settings โ†’ File Uploads in the dashboard.

It takes a small amount of JavaScript: request an upload policy, upload the file to storage, then submit the form with the file's ID.

const FORM_ID = "YOUR_FORM_ID";

async function uploadFile(file) {
  // 1. Ask Formtorch for a short-lived upload policy for this file.
  const intentRes = await fetch("https://formtorch.com/api/uploads/intent", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      formId: FORM_ID,
      files: [{ name: file.name, mimeType: file.type, size: file.size }],
    }),
  });
  const intent = await intentRes.json().catch(() => ({}));
  if (!intentRes.ok)
    throw new Error(intent.message ?? "Upload was not accepted.");
  const { fileId, uploadUrl, fields } = intent.files[0];

  // 2. POST the file straight to storage. The file must be the last field.
  const storageForm = new FormData();
  for (const [key, value] of Object.entries(fields))
    storageForm.append(key, value);
  storageForm.append("file", file);
  const storageRes = await fetch(uploadUrl, {
    method: "POST",
    body: storageForm,
  });
  if (!storageRes.ok)
    throw new Error("The file upload failed. Please try again.");

  return fileId;
}

Then the submit handler uploads the file first and sends the rest of the form with a _file_ids field that maps the form field name to the uploaded file's ID:

form.addEventListener("submit", async (event) => {
  event.preventDefault();
  const data = new FormData(form);
  const file = data.get("resume");

  try {
    if (file instanceof File && file.size > 0) {
      const fileId = await uploadFile(file);
      data.delete("resume"); // the bytes are already in storage
      data.append("_file_ids", JSON.stringify({ resume: fileId }));
    }

    // 3. Submit the form, referencing the uploaded file by ID.
    const res = await fetch(`https://formtorch.com/f/${FORM_ID}`, {
      method: "POST",
      headers: { Accept: "application/json" },
      body: data,
    });
    const result = await res.json().catch(() => ({}));
    if (!res.ok) throw new Error(result.message ?? "Submission failed.");

    form.reset(); // show your success message here
  } catch (err) {
    // show err.message next to the form
  }
});

Replace YOUR_FORM_ID with the ID from your form's endpoint URL in the dashboard. The Accept: application/json header tells Formtorch to answer with JSON instead of redirecting, so your script can read the result. If the form has Cloudflare Turnstile enabled, add the widget token to the upload policy request as turnstileToken; the final submission then goes through without a second token.

The checks from earlier happen on Formtorch's side:

  • Limits: up to 25 MiB per file, 10 files per submission, and 100 MiB per submission, with 1 GB of total storage on Starter and 5 GB on Pro.
  • Types: an allowlist of common document, image, and text formats (PDF, Word, Excel, PNG, JPEG, WebP, GIF, plain text, and CSV), verified against the file's actual bytes. Each form can narrow the list further.
  • Scanning: every file is scanned for malware before it can be downloaded, and a file that fails is quarantined.
  • Delivery: notification emails include a signed download link for each file, and webhook payloads include a download URL.
Warning

A plain HTML form with enctype="multipart/form-data" pointed at Formtorch still submits, but the file isn't stored: the submission records a placeholder with the filename and size. Use the JavaScript flow above when you need the file itself.

For a styled, copy-paste version with drag and drop and multiple files, the file upload form template has this flow ready for HTML, React, Next.js, and Astro. The file uploads documentation covers error codes, retention, and downloading files through the API.

Wrapping up

File uploads come down to one encoding detail and a handful of trust decisions. Set method="POST" and enctype="multipart/form-data", or let FormData build the request and keep your hands off the Content-Type header. Then treat everything the client tells you about a file, its name, its type, its size, as a claim to verify rather than a fact.

Whether those checks live in your own Express route or in a service, they're the same checks. What changes is who maintains them.

Need file uploads without the pipeline?

Formtorch handles storage, type checks, and malware scanning for form uploads. Start free, then turn on uploads when you upgrade.

Related posts
Building a Node.js contact form that sends email
How to build a Node.js contact form that sends email
TutorialsSeptember 3, 202613 min read
The complete guide to contact forms for developers
The complete guide to contact forms for developers
TutorialsMay 20, 202612 min read