Skip to main content
Node.js · lesson 13 of 20
Node.js intermediate Lesson 13 of 20

File Uploads with multer

An unhandled MulterError crashes into a 500; catch its codes for 413 and 400, see busboy strip a traversal filename, and fool a fileFilter with a lie.

By SkillByExample · Updated

nodejs express multer file-uploads

A multipart/form-data upload is just a request body with a different encoding — multer parses it into req.file, but what it does with a file that’s too big, has the wrong field name, or carries a hostile filename depends entirely on whether anything downstream is actually checking for those cases.

An Unhandled MulterError Is a Generic 500

The plainest possible upload route: one field, avatar, with a 1024-byte size limit, and nothing watching for errors from multer itself.

// server-plain.js
import express from "express";
import multer from "multer";

const upload = multer({
  dest: "uploads/",
  limits: { fileSize: 1024 },
});

const app = express();

app.get("/healthz", (req, res) => res.json({ status: "ok" }));

app.post("/avatar", upload.single("avatar"), (req, res) => {
  res.status(201).json({
    field: req.file.fieldname,
    original: req.file.originalname,
    stored: req.file.filename,
    size: req.file.size,
  });
});

app.listen(3000, () => console.log("listening on http://localhost:3000"));

package.json pins the two dependencies this entire lesson uses, with no ^ ranges so a reader installs exactly what produced the output below:

{
  "name": "file-uploads-with-multer",
  "version": "1.0.0",
  "type": "module",
  "dependencies": {
    "express": "5.3.0",
    "multer": "2.5.0"
  }
}

node_modules lives in its own named Docker volume, nodejs-file-uploads-with-multer-modules — a bind mount shares files with the Linux VM slowly enough that installing straight into it is minutes slower than a volume:

$ docker run --rm -v "$(pwd)":/app -v nodejs-file-uploads-with-multer-modules:/app/node_modules -w /app -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npm install --no-audit --no-fund

added 73 packages in 4s

Two files: a small one well under the limit, and one at roughly double it, to upload against the same route:

$ echo "a small avatar" > small.txt
$ head -c 2000 /dev/zero | tr '\0' 'x' > big.txt
$ docker run -d --name nodejs-uploads-plain -p 3000:3000 -v "$(pwd)":/app -v nodejs-file-uploads-with-multer-modules:/app/node_modules -w /app node:26.13 node server-plain.js
b1ffd13a556c2895be74ad5db147978a70f82bbf25ebaf16393a77f7df526e7d
$ until curl -s -o /dev/null http://localhost:3000/healthz; do sleep 0.3; done
$ curl -s -i -X POST http://localhost:3000/avatar -F "avatar=@small.txt"
HTTP/1.1 201 Created
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 95
ETag: W/"5f-EpElqDL1KqA78+V9n/vwi8XPGkI"
Date: Tue, 03 Nov 2026 12:32:41 GMT
Connection: keep-alive
Keep-Alive: timeout=5

{"field":"avatar","original":"small.txt","stored":"db79c4ae86e56f1a61c951fe32c8c03c","size":15}
$ curl -s -i -X POST http://localhost:3000/avatar -F "avatar=@big.txt"
HTTP/1.1 500 Internal Server Error
X-Powered-By: Express
Content-Security-Policy: default-src 'none'
X-Content-Type-Options: nosniff
Content-Type: text/html; charset=utf-8
Content-Length: 973
Date: Tue, 03 Nov 2026 12:32:43 GMT
Connection: keep-alive
Keep-Alive: timeout=5

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Error</title>
</head>
<body>
<pre>MulterError: File too large<br> &nbsp; &nbsp;at abortWithCode (/app/node_modules/multer/lib/make-middleware.js:225:22)<br> &nbsp; &nbsp;at FileStream.&lt;anonymous&gt; (/app/node_modules/multer/lib/make-middleware.js:330:11)<br> &nbsp; &nbsp;at FileStream.emit (node:events:514:20)<br> &nbsp; &nbsp;at SBMH.ssCb [as _cb] (/app/node_modules/busboy/lib/types/multipart.js:479:32)<br> &nbsp; &nbsp;at feed (/app/node_modules/streamsearch/lib/sbmh.js:219:14)<br> &nbsp; &nbsp;at SBMH.push (/app/node_modules/streamsearch/lib/sbmh.js:104:16)<br> &nbsp; &nbsp;at Multipart._write (/app/node_modules/busboy/lib/types/multipart.js:567:19)<br> &nbsp; &nbsp;at writeOrBuffer (node:internal/streams/writable:574:12)<br> &nbsp; &nbsp;at _write (node:internal/streams/writable:503:10)<br> &nbsp; &nbsp;at Writable.write (node:internal/streams/writable:512:10)</pre>
</body>
</html>
$ docker rm -f nodejs-uploads-plain
nodejs-uploads-plain

The 15-byte small.txt comes back 201 Created with multer’s own metadata about the file. big.txt is 2000 bytes against a 1024-byte limit, so multer’s upload.single("avatar") middleware calls next(err) with a MulterError — and because nothing downstream ever added an error-handling middleware, Express’s own default handler catches it and answers 500 Internal Server Error with an HTML page that includes the full stack trace, straight out of node_modules/multer and node_modules/busboy inside the container. The size limit did its job; nothing told the client why in a way an API consumer could act on, and the response leaked internal file paths in the process.

That stack trace is also conditional on one environment variable: Express’s own default handler only writes it into the response when NODE_ENV isn’t "production". Nothing in server-plain.js changed — only the container’s environment did:

$ docker run -d --name nodejs-uploads-plain-prod -p 3000:3000 -e NODE_ENV=production -v "$(pwd)":/app -v nodejs-file-uploads-with-multer-modules:/app/node_modules -w /app node:26.13 node server-plain.js
1ef1c60cb1253c5de50ce80755b14627e5cd577fea23be4939150751827862a6
$ until curl -s -o /dev/null http://localhost:3000/healthz; do sleep 0.3; done
$ curl -s -i -X POST http://localhost:3000/avatar -F "avatar=@big.txt"
HTTP/1.1 500 Internal Server Error
X-Powered-By: Express
Content-Security-Policy: default-src 'none'
X-Content-Type-Options: nosniff
Content-Type: text/html; charset=utf-8
Content-Length: 148
Date: Tue, 03 Nov 2026 12:32:50 GMT
Connection: keep-alive
Keep-Alive: timeout=5

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Error</title>
</head>
<body>
<pre>Internal Server Error</pre>
</body>
</html>
$ docker rm -f nodejs-uploads-plain-prod
nodejs-uploads-plain-prod

Same oversized file, same route, same missing error-handling middleware — but the body is now just Internal Server Error, with none of the earlier page’s stack trace or node_modules paths in it. That’s Express’s own default handler switching behavior on a single environment variable, not anything this lesson’s code checks for — which is exactly why the handled version in the next section answers with a real status code and a readable message instead of depending on it.

Catching MulterError by Its code

Multer’s errors all share one class, multer.MulterError, with a code property naming exactly what went wrong. A four-argument error-handling middleware, registered after the route, maps the two codes this lesson triggers to real status codes:

// server-handled.js
import express from "express";
import multer from "multer";

const upload = multer({
  dest: "uploads/",
  limits: { fileSize: 1024 },
});

const app = express();

app.get("/healthz", (req, res) => res.json({ status: "ok" }));

app.post("/avatar", upload.single("avatar"), (req, res) => {
  res.status(201).json({
    field: req.file.fieldname,
    original: req.file.originalname,
    stored: req.file.filename,
    size: req.file.size,
  });
});

app.use((err, req, res, next) => {
  if (err instanceof multer.MulterError) {
    if (err.code === "LIMIT_FILE_SIZE") {
      res.status(413).json({ error: err.code, message: "File too large" });
      return;
    }
    if (err.code === "LIMIT_UNEXPECTED_FILE") {
      res.status(400).json({ error: err.code, message: `Unexpected field "${err.field}"` });
      return;
    }
  }
  res.status(500).json({ error: "internal error" });
});

app.listen(3000, () => console.log("listening on http://localhost:3000"));

Three requests, each changing exactly one thing from the success case: the same oversized file as before, then the same small file under a field name the route never asked for:

$ docker run -d --name nodejs-uploads-handled -p 3000:3000 -v "$(pwd)":/app -v nodejs-file-uploads-with-multer-modules:/app/node_modules -w /app node:26.13 node server-handled.js
c4c289267a0eeb7ecf1d70aa37a3f1439fbae07b2c524f2f4a02e4e141703dc9
$ until curl -s -o /dev/null http://localhost:3000/healthz; do sleep 0.3; done
$ curl -s -i -X POST http://localhost:3000/avatar -F "avatar=@small.txt"
HTTP/1.1 201 Created
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 95
ETag: W/"5f-XN6dwsB1hrvMexuAFLzBxXWEunA"
Date: Tue, 03 Nov 2026 12:32:56 GMT
Connection: keep-alive
Keep-Alive: timeout=5

{"field":"avatar","original":"small.txt","stored":"78e414c41fc704e146b4c168e37da9d7","size":15}
$ curl -s -i -X POST http://localhost:3000/avatar -F "avatar=@big.txt"
HTTP/1.1 413 Payload Too Large
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 54
ETag: W/"36-r1sQ5w0H606K4infMuq1tRU4tPk"
Date: Tue, 03 Nov 2026 12:32:57 GMT
Connection: keep-alive
Keep-Alive: timeout=5

{"error":"LIMIT_FILE_SIZE","message":"File too large"}
$ curl -s -i -X POST http://localhost:3000/avatar -F "photo=@small.txt"
HTTP/1.1 400 Bad Request
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 72
ETag: W/"48-uGXgRETkOcElfDj3YGkspmpFo74"
Date: Tue, 03 Nov 2026 12:32:57 GMT
Connection: keep-alive
Keep-Alive: timeout=5

{"error":"LIMIT_UNEXPECTED_FILE","message":"Unexpected field \"photo\""}

Same oversized file, same route, but now a 413 Payload Too Large with {"error":"LIMIT_FILE_SIZE",...} instead of a stack trace. The third request keeps the file small and valid — only the field name changed, from avatar to photo — and multer’s LIMIT_UNEXPECTED_FILE fires because upload.single("avatar") only ever expects that one named field; the middleware reports it as 400 with the field name quoted from err.field, not a guess.

What multer Does to a Client-Supplied Filename

The same nodejs-uploads-handled container is still running. This time the upload’s filename itself is an attempted path-traversal string:

$ curl -s -X POST http://localhost:3000/avatar -F "avatar=@small.txt;filename=../../etc/x"
{"field":"avatar","original":"x","stored":"6581ec00d0908ee46b542a68386600b6","size":15}
$ curl -s -X POST http://localhost:3000/avatar -F "avatar=@small.txt;filename=..\\..\\etc\\x"
{"field":"avatar","original":"x","stored":"a50a3af46fef75558a7ec4f2dcc0f40a","size":15}
$ docker rm -f nodejs-uploads-handled
nodejs-uploads-handled

Both original fields come back x, whether the traversal used forward slashes or backslashes — not because multer sanitizes anything itself, but because busboy, the multipart parser multer wraps, strips the filename down to a basename before multer’s code ever runs:

$ docker run --rm -v "$(pwd)":/app -v nodejs-file-uploads-with-multer-modules:/app/node_modules -w /app node:26.13 sh -c "grep -n -A 12 'function basename' node_modules/busboy/lib/utils.js"
479:function basename(path) {
480-  if (typeof path !== 'string')
481-    return '';
482-  for (let i = path.length - 1; i >= 0; --i) {
483-    switch (path.charCodeAt(i)) {
484-      case 0x2F: // '/'
485-      case 0x5C: // '\'
486-        path = path.slice(i + 1);
487-        return (path === '..' || path === '.' ? '' : path);
488-    }
489-  }
490-  return (path === '..' || path === '.' ? '' : path);
491-}

0x2F and 0x5C are the character codes for / and \ — this basename() strips both unconditionally, unlike Node’s own path.basename, which only treats / as a separator on Linux. That’s also why a custom filename callback that naively uses file.originalname directly is still safe from this particular attack: by the time multer’s own code runs, file.originalname has already lost every directory segment.

// server-naive.js
import express from "express";
import multer from "multer";

const storage = multer.diskStorage({
  destination: "uploads/",
  filename: (req, file, cb) => cb(null, file.originalname), // looks risky; busboy already stripped the path by here
});

const upload = multer({ storage, limits: { fileSize: 1024 } });
const app = express();

app.get("/healthz", (req, res) => res.json({ status: "ok" }));

app.post("/avatar", upload.single("avatar"), (req, res) => {
  res.status(201).json({ original: req.file.originalname, stored: req.file.filename, path: req.file.path });
});

app.use((err, req, res, next) => res.status(500).json({ error: err.message }));

app.listen(3000, () => console.log("listening on http://localhost:3000"));
$ docker run -d --name nodejs-uploads-naive -p 3000:3000 -v "$(pwd)":/app -v nodejs-file-uploads-with-multer-modules:/app/node_modules -w /app node:26.13 node server-naive.js
c983374c5ec29bcfadcc25da8f499123c4b5294de362c7555cf4c7311ff3c245
$ until curl -s -o /dev/null http://localhost:3000/healthz; do sleep 0.3; done
$ curl -s -X POST http://localhost:3000/avatar -F "avatar=@small.txt;filename=../../../etc/passwd"
{"original":"passwd","stored":"passwd","path":"uploads/passwd"}
$ docker rm -f nodejs-uploads-naive
nodejs-uploads-naive

stored and path both show passwd, not etc/passwd or anything above uploads/ — file.originalname was already reduced to passwd before this callback ever ran, so even this deliberately naive storage function never saw the traversal attempt at all.

That protection is conditional on busboy’s own default behavior, not anything multer’s naive callback does itself. multer’s preservePath: true option tells busboy to skip basename() and hand over the filename exactly as the client sent it — which brings the same naive callback’s risk straight back:

// server-naive-preservepath.js
import express from "express";
import multer from "multer";

const storage = multer.diskStorage({
  destination: "uploads/",
  filename: (req, file, cb) => cb(null, file.originalname),
});

const upload = multer({ storage, preservePath: true, limits: { fileSize: 1024 } });
const app = express();

app.get("/healthz", (req, res) => res.json({ status: "ok" }));

app.post("/avatar", upload.single("avatar"), (req, res) => {
  res.status(201).json({ original: req.file.originalname, stored: req.file.filename, path: req.file.path });
});

app.use((err, req, res, next) => res.status(500).json({ error: err.message }));

app.listen(3000, () => console.log("listening on http://localhost:3000"));
$ docker run -d --name nodejs-uploads-naive-pp -p 3000:3000 -v "$(pwd)":/app -v nodejs-file-uploads-with-multer-modules:/app/node_modules -w /app node:26.13 node server-naive-preservepath.js
b038343a6cfa0aad480e8b181194e72a808ecef12481fb8a1ea90ad6c951be09
$ until curl -s -o /dev/null http://localhost:3000/healthz; do sleep 0.3; done
$ curl -s -X POST http://localhost:3000/avatar -F "avatar=@small.txt;filename=../x.txt"
{"original":"../x.txt","stored":"../x.txt","path":"x.txt"}
$ docker exec nodejs-uploads-naive-pp sh -c "ls /app/x.txt"
/app/x.txt
$ docker rm -f nodejs-uploads-naive-pp
nodejs-uploads-naive-pp

original and stored both come back ../x.txt this time — busboy never touched it — and path is x.txt, resolved relative to uploads/’s parent, /app, not inside uploads/ itself. ls /app/x.txt finds the file sitting one directory above the destination the server was configured with. And even without preservePath, the base filename itself is still whatever the client sent: this same naive callback would let one upload silently overwrite another user’s file of the same name, create a hidden dotfile, or accept a client-chosen extension like .html — stored XSS if that file is ever served back to a browser.

A fileFilter Checks a Claim, Not the Bytes

fileFilter runs before a file is saved and can reject it outright — but the only thing it has to check is file.mimetype, the Content-Type the client’s own multipart request declared for that part:

// server-filter.js
import express from "express";
import multer from "multer";

const upload = multer({
  dest: "uploads/",
  limits: { fileSize: 1024 },
  fileFilter(req, file, cb) {
    if (file.mimetype !== "image/png" && file.mimetype !== "image/jpeg") {
      cb(new Error(`unsupported mimetype: ${file.mimetype}`));
      return;
    }
    cb(null, true);
  },
});

const app = express();

app.get("/healthz", (req, res) => res.json({ status: "ok" }));

app.post("/avatar", upload.single("avatar"), (req, res) => {
  res.status(201).json({
    original: req.file.originalname,
    mimetype: req.file.mimetype,
    stored: req.file.filename,
  });
});

app.use((err, req, res, next) => {
  res.status(400).json({ error: "upload rejected", message: err.message });
});

app.listen(3000, () => console.log("listening on http://localhost:3000"));

Three uploads: a real PNG, the same small text file as every earlier test, and that same text file again — relabeled with a .png name and an image/png type, both of which curl lets a caller set by hand:

$ printf '\x89PNG\r\n\x1a\n' > fake.png
$ docker run -d --name nodejs-uploads-filter -p 3000:3000 -v "$(pwd)":/app -v nodejs-file-uploads-with-multer-modules:/app/node_modules -w /app node:26.13 node server-filter.js
bbf908897cefc983fe461930e8a307c29a1c4da14b8c36781de56dc18f3bf4ee
$ until curl -s -o /dev/null http://localhost:3000/healthz; do sleep 0.3; done
$ curl -s -X POST http://localhost:3000/avatar -F "avatar=@fake.png"
{"original":"fake.png","mimetype":"image/png","stored":"23e92671c9c83f53b78b8a4b2b15b609"}
$ curl -s -X POST http://localhost:3000/avatar -F "avatar=@small.txt"
{"error":"upload rejected","message":"unsupported mimetype: text/plain"}
$ curl -s -X POST http://localhost:3000/avatar -F "avatar=@small.txt;filename=fake.png;type=image/png"
{"original":"fake.png","mimetype":"image/png","stored":"b39930220c3164a7b3c1ccd04f825078"}
$ docker rm -f nodejs-uploads-filter
nodejs-uploads-filter

The real PNG passes with "mimetype":"image/png". small.txt on its own is rejected — curl guesses text/plain from the .txt extension, which fails the allowlist. But the third request is the exact same plain-text bytes as the second, with only its filename and declared Content-Type changed on the command line — and it’s accepted, reporting "mimetype":"image/png" for a file that is still, byte for byte, plain text. fileFilter never opened the file to check; it trusted a header the client wrote itself.

None of this lesson’s checks read a single byte of file content: the size limit counts bytes without looking at them, the field-name check only reads request metadata, and fileFilter reads a header the client controls. None of it defends against a malicious payload inside an otherwise-valid-looking file, a virus-scanned upload, or a user quietly filling a disk with uploads under the size cap — see Unsafe File Uploads: secure_filename, Allowlists and Size Limits for checking a file’s actual leading bytes, naming an extension allowlist, and the size-limit and random-filename patterns this lesson’s own examples already lean on.

Practice

1. Raise server-handled.js’s limits.fileSize from 1024 to 5120, and confirm the same 2000-byte big.txt that triggered 413 earlier now uploads successfully.

Solution
// server-handled.js (fileSize raised)
import express from "express";
import multer from "multer";

const upload = multer({
  dest: "uploads/",
  limits: { fileSize: 5120 }, // was 1024
});

const app = express();

app.get("/healthz", (req, res) => res.json({ status: "ok" }));

app.post("/avatar", upload.single("avatar"), (req, res) => {
  res.status(201).json({
    field: req.file.fieldname,
    original: req.file.originalname,
    stored: req.file.filename,
    size: req.file.size,
  });
});

app.use((err, req, res, next) => {
  if (err instanceof multer.MulterError) {
    if (err.code === "LIMIT_FILE_SIZE") {
      res.status(413).json({ error: err.code, message: "File too large" });
      return;
    }
    if (err.code === "LIMIT_UNEXPECTED_FILE") {
      res.status(400).json({ error: err.code, message: `Unexpected field "${err.field}"` });
      return;
    }
  }
  res.status(500).json({ error: "internal error" });
});

app.listen(3000, () => console.log("listening on http://localhost:3000"));
$ docker run -d --name nodejs-uploads-handled -p 3000:3000 -v "$(pwd)":/app -v nodejs-file-uploads-with-multer-modules:/app/node_modules -w /app node:26.13 node server-handled.js
cb81953eff30d8dbaf5e3a8f25235959189301d4a5b1f8630c43e693e268ef45
$ until curl -s -o /dev/null http://localhost:3000/healthz; do sleep 0.3; done
$ curl -s -i -X POST http://localhost:3000/avatar -F "avatar=@big.txt"
HTTP/1.1 201 Created
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 95
ETag: W/"5f-Hbj129ddfntbWM4Rz6a3HhtmKeE"
Date: Tue, 03 Nov 2026 12:33:32 GMT
Connection: keep-alive
Keep-Alive: timeout=5

{"field":"avatar","original":"big.txt","stored":"aed9fd2942ef6f6af06c0e889b89f42c","size":2000}
$ docker rm -f nodejs-uploads-handled
nodejs-uploads-handled

201 Created this time — the same 2000-byte file is now comfortably under the new 5120-byte limit, so multer never raises LIMIT_FILE_SIZE at all.

2. Add limits.files: 1 to server-handled.js’s existing upload.single("avatar") route (at most one file in the whole request) and upload two files under the same field name in one request. Confirm the response is a generic 500 first, then add a case for LIMIT_FILE_COUNT to the error middleware and confirm it becomes a 400.

Solution

maxCount on upload.array("avatar", 1) — effectively what the main example’s upload.single("avatar") already is — reports going over as LIMIT_UNEXPECTED_FILE, the same code the wrong-field-name test triggered earlier. LIMIT_FILE_COUNT is a separate limit: multer’s own make-middleware.js wires busboy’s filesLimit event straight to LIMIT_FILE_COUNT regardless of which upload.* method sits in front of it, so adding limits.files: 1 to upload.single("avatar") triggers it the exact same way it would on upload.any() or upload.array() — nothing about the method matters, only the count:

// server-handled.js (limits.files added, LIMIT_FILE_COUNT not handled yet)
import express from "express";
import multer from "multer";

const upload = multer({
  dest: "uploads/",
  limits: { fileSize: 1024, files: 1 },
});

const app = express();

app.get("/healthz", (req, res) => res.json({ status: "ok" }));

app.post("/avatar", upload.single("avatar"), (req, res) => {
  res.status(201).json({ field: req.file.fieldname, stored: req.file.filename });
});

app.use((err, req, res, next) => {
  if (err instanceof multer.MulterError) {
    if (err.code === "LIMIT_FILE_SIZE") {
      res.status(413).json({ error: err.code, message: "File too large" });
      return;
    }
    if (err.code === "LIMIT_UNEXPECTED_FILE") {
      res.status(400).json({ error: err.code, message: `Unexpected field "${err.field}"` });
      return;
    }
  }
  res.status(500).json({ error: "internal error" });
});

app.listen(3000, () => console.log("listening on http://localhost:3000"));
$ docker run -d --name nodejs-uploads-handled -p 3000:3000 -v "$(pwd)":/app -v nodejs-file-uploads-with-multer-modules:/app/node_modules -w /app node:26.13 node server-handled.js
75a77db47bae81ff6bb9e93167494af2d1f4869ad7265711d362f7e8ea2b9f25
$ until curl -s -o /dev/null http://localhost:3000/healthz; do sleep 0.3; done
$ curl -s -i -X POST http://localhost:3000/avatar -F "avatar=@small.txt" -F "avatar=@small.txt"
HTTP/1.1 500 Internal Server Error
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 26
ETag: W/"1a-nO1SMtFqjAylRZvcD/JJ2LGv/qs"
Date: Tue, 03 Nov 2026 12:33:41 GMT
Connection: keep-alive
Keep-Alive: timeout=5

{"error":"internal error"}
$ docker rm -f nodejs-uploads-handled
nodejs-uploads-handled

That first run’s error middleware has no LIMIT_FILE_COUNT branch, so multer’s next(err) falls through every if, and the catch-all’s own res.status(500).json({ error: "internal error" }) is what answers — a generic 500, same as the very first, unhandled example in this lesson, not a crash the process itself didn’t survive. Add a branch for it:

// server-handled.js (limits.files added, LIMIT_FILE_COUNT handled)
import express from "express";
import multer from "multer";

const upload = multer({
  dest: "uploads/",
  limits: { fileSize: 1024, files: 1 },
});

const app = express();

app.get("/healthz", (req, res) => res.json({ status: "ok" }));

app.post("/avatar", upload.single("avatar"), (req, res) => {
  res.status(201).json({ field: req.file.fieldname, stored: req.file.filename });
});

app.use((err, req, res, next) => {
  if (err instanceof multer.MulterError) {
    if (err.code === "LIMIT_FILE_SIZE") {
      res.status(413).json({ error: err.code, message: "File too large" });
      return;
    }
    if (err.code === "LIMIT_UNEXPECTED_FILE") {
      res.status(400).json({ error: err.code, message: `Unexpected field "${err.field}"` });
      return;
    }
    if (err.code === "LIMIT_FILE_COUNT") { // new
      res.status(400).json({ error: err.code, message: "Too many files" }); // new
      return; // new
    }
  }
  res.status(500).json({ error: "internal error" });
});

app.listen(3000, () => console.log("listening on http://localhost:3000"));

Same two-file request, against a fresh container running the edited file:

$ docker run -d --name nodejs-uploads-handled -p 3000:3000 -v "$(pwd)":/app -v nodejs-file-uploads-with-multer-modules:/app/node_modules -w /app node:26.13 node server-handled.js
48b66cdb0bddeca92c22cec5e79119f9060dd2cb83c013d68455f3a2ddccdf4f
$ until curl -s -o /dev/null http://localhost:3000/healthz; do sleep 0.3; done
$ curl -s -i -X POST http://localhost:3000/avatar -F "avatar=@small.txt" -F "avatar=@small.txt"
HTTP/1.1 400 Bad Request
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 55
ETag: W/"37-bc1uXe7Pn9piOTKx1vaBYfA1sUw"
Date: Tue, 03 Nov 2026 12:33:48 GMT
Connection: keep-alive
Keep-Alive: timeout=5

{"error":"LIMIT_FILE_COUNT","message":"Too many files"}
$ docker rm -f nodejs-uploads-handled
nodejs-uploads-handled

Same two-file request, same files: 1 limit — but now 400 Bad Request with {"error":"LIMIT_FILE_COUNT","message":"Too many files"}, because the only thing that changed between the two runs is whether that err.code had a branch waiting for it.

3. Add "image/webp" to server-filter.js’s mimetype allowlist, then repeat the spoofing request from the main example with a .webp name and image/webp type instead of .png/image/png, and confirm it’s still accepted.

Solution
// server-filter.js (webp added to the allowlist)
import express from "express";
import multer from "multer";

const upload = multer({
  dest: "uploads/",
  limits: { fileSize: 1024 },
  fileFilter(req, file, cb) {
    if (
      file.mimetype !== "image/png" &&
      file.mimetype !== "image/jpeg" &&
      file.mimetype !== "image/webp" // new
    ) {
      cb(new Error(`unsupported mimetype: ${file.mimetype}`));
      return;
    }
    cb(null, true);
  },
});

const app = express();

app.get("/healthz", (req, res) => res.json({ status: "ok" }));

app.post("/avatar", upload.single("avatar"), (req, res) => {
  res.status(201).json({
    original: req.file.originalname,
    mimetype: req.file.mimetype,
    stored: req.file.filename,
  });
});

app.use((err, req, res, next) => {
  res.status(400).json({ error: "upload rejected", message: err.message });
});

app.listen(3000, () => console.log("listening on http://localhost:3000"));
$ docker run -d --name nodejs-uploads-filter -p 3000:3000 -v "$(pwd)":/app -v nodejs-file-uploads-with-multer-modules:/app/node_modules -w /app node:26.13 node server-filter.js
7fec7b233a607c241172cb97711c2870b6ffd48e8d8fca52126a6f1f2780839c
$ until curl -s -o /dev/null http://localhost:3000/healthz; do sleep 0.3; done
$ curl -s -X POST http://localhost:3000/avatar -F "avatar=@small.txt;filename=fake.webp;type=image/webp"
{"original":"fake.webp","mimetype":"image/webp","stored":"066a702571bf6d22b9aa9d0c54538437"}
$ docker rm -f nodejs-uploads-filter
nodejs-uploads-filter

Still 201, still plain text underneath, now reported as "mimetype":"image/webp". Widening the allowlist widens exactly what fileFilter will believe when a client lies about it — it does nothing to make the check itself read any actual bytes.

4. After a 413 from an oversized upload, confirm no new file was actually written to the upload folder.

Solution

uploads/ is a plain folder inside this lesson’s own project directory, bind-mounted into every container the lesson has started — every earlier section’s successful uploads are still sitting in it, so checking that it’s empty isn’t the right test any more. Counting its files before and after the oversized request is, reusing server-handled.js as Practice 2 last left it — limits.files: 1 and the LIMIT_FILE_COUNT branch are both still in there, but this request only uploads one file, so neither one fires; only the original limits.fileSize: 1024 is in play, the same as in the main example:

$ docker run -d --name nodejs-uploads-handled -p 3000:3000 -v "$(pwd)":/app -v nodejs-file-uploads-with-multer-modules:/app/node_modules -w /app node:26.13 node server-handled.js
41eff0c08da90b228844afb9d2807e6cad81d23dd99099a2ab6dce0aa38b69fb
$ until curl -s -o /dev/null http://localhost:3000/healthz; do sleep 0.3; done
$ before=$(docker exec nodejs-uploads-handled sh -c 'ls uploads | wc -l'); echo "$before"
9
$ curl -s -o /dev/null -w '%{http_code}\n' -X POST http://localhost:3000/avatar -F "avatar=@big.txt"
413
$ after=$(docker exec nodejs-uploads-handled sh -c 'ls uploads | wc -l'); echo "$after"
9
$ [ "$before" = "$after" ] && echo SAME
SAME
$ docker rm -f nodejs-uploads-handled
nodejs-uploads-handled

413 comes back, and the file count is identical before and after — SAME. multer removes the partially-written file itself once the size limit trips mid-stream; a rejected upload never leaves bytes behind for a client to retry into eventually filling the disk one byte-limit violation at a time.

Frequently Asked Questions

Why does an unhandled MulterError turn into a generic 500 instead of a specific status code?
multer's upload middleware calls Express's next(err) when something goes wrong — a file over the size limit, a field name it wasn't told to expect — exactly like any other middleware error. Express 5's built-in handler reads err.status or err.statusCode when either is set, and only falls back to 500 when neither one exists; a MulterError never sets either property, so this particular failure lands on 500, not because Express always answers 500 for every error. The fix is a four-argument middleware that checks err instanceof multer.MulterError and reads err.code.
Why does multer store uploads under random hex names with no extension?
multer's default disk storage names every file with 32 random hex characters from crypto.randomBytes(16).toString('hex'), specifically so two uploads can never collide and a client's filename never reaches the disk. That string is different on every single run, so this lesson masks it rather than printing a value nobody could reproduce.
Does multer protect against a filename like ../../etc/passwd?
Partly, and only by default. The protection happens one layer below multer, inside busboy, the multipart parser multer wraps: its own basename() function strips everything up to and including the last / or \ in the Content-Disposition filename before multer's code ever sees it, so file.originalname is already just passwd by the time any of multer's own logic runs. Setting multer's own preservePath: true option turns that stripping off, which brings the traversal risk straight back for any storage callback that uses file.originalname. See Unsafe File Uploads: secure_filename, Allowlists and Size Limits (/web-security/file-upload-validation/) for the fuller threat model, including attacks the default stripping does nothing about.
If a fileFilter rejects the wrong MIME type, is the upload safe from malicious content?
No — file.mimetype inside fileFilter is just the Content-Type the client's multipart request declared for that part, a header the client fully controls and can set to anything. This lesson shows a plain text file accepted as image/png by relabeling it and lying about its type; fileFilter never reads the bytes to check. A real content check reads the file's leading magic bytes, which Unsafe File Uploads: secure_filename, Allowlists and Size Limits (/web-security/file-upload-validation/) covers for a PNG signature.
What Express and multer versions does this lesson use?
Express 5.3.0 and multer 2.5.0, on Node.js 26.13.0 (Docker node:26.13). multer 2.x wraps busboy 1.6.0 for multipart parsing.