Uploads are a common feature and a common attack vector; limits and validation are not optional.
HTML forms and FormData send files as multipart/form-data, which express.json() and express.urlencoded() don't parse. multer, the Express team's middleware, does.
const upload = multer({ dest: "uploads/", limits: { fileSize: 5 * 1024 * 1024 } });
app.post("/avatar", upload.single("avatar"), handler);upload.single(field)puts one file onreq.file;upload.array(field, max)andupload.fields([...])put several onreq.files. Text fields land onreq.body.- Each file object has
originalname,mimetype,size, and eitherpath(disk storage) orbuffer(memory storage).
Storage choices:
- Disk (
destormulter.diskStorage) — files are streamed to disk, so memory stays flat. Multer generates random filenames; never useoriginalnameas a path. - Memory (
multer.memoryStorage()) — convenient for small files you process immediately (resize an avatar), dangerous for large ones because the whole file sits in RAM. - For S3-like storage, stream from disk after validation, or use a storage engine that streams directly. Many apps skip the server entirely: hand the client a presigned upload URL and only store the resulting key.
Always set limits — fileSize, files, fields — or a single request can fill your disk or memory. Exceeding them raises a MulterError with a code like LIMIT_FILE_SIZE, which you map to 413 in your error handler.
mimetype and the extension come from the client and can lie. Use fileFilter for a first check, then verify content (magic bytes, or decode the image) before trusting it. Serve uploaded files from a separate domain or with Content-Disposition: attachment so a malicious HTML upload can't run in your origin.
Example
import express from "express";
import multer from "multer";
import path from "node:path";
const app = express();
const upload = multer({
dest: path.join(import.meta.dirname, "uploads"), // random filenames, streamed to disk
limits: { fileSize: 2 * 1024 * 1024, files: 1 }, // 2 MB, one file
fileFilter(req, file, cb) {
const ok = ["image/png", "image/jpeg", "image/webp"].includes(file.mimetype);
cb(ok ? null : Object.assign(new Error("only PNG, JPEG or WebP"), { status: 415 }), ok);
},
});
app.post("/avatar", upload.single("avatar"), (req, res) => {
if (!req.file) return res.status(400).json({ error: "avatar file is required" });
// TODO: verify magic bytes / decode image before trusting it
res.status(201).json({ stored: req.file.filename, size: req.file.size, caption: req.body.caption });
});
app.use((err, req, res, next) => {
if (err instanceof multer.MulterError) {
return res.status(err.code === "LIMIT_FILE_SIZE" ? 413 : 400).json({ error: err.code });
}
res.status(err.status ?? 500).json({ error: err.status ? err.message : "Internal Server Error" });
});
app.listen(3000);
// curl -F avatar=@me.png -F caption=hi localhost:3000/avatarRemember
- multer parses multipart/form-data;
req.file/req.filesplusreq.body - Always set
limits; mapMulterErrorcodes to 400/413 - Disk storage for size, memory only for small files; never trust
originalname - Client MIME types lie — verify content before use
Watch out for
- Using memoryStorage without a fileSize limit, letting one upload exhaust RAM.
- Saving files under the client-supplied original name, enabling overwrites and path tricks.