File Uploads and Downloads
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Express 5.3 (npm express 5.3.0, published 2026-10-09) on Node.js 18 or later, the minimum
for Express 5, and it states where Express 4 (latest 4.22.3) behaves differently. The facts were checked against the
official documentation and the package repositories on 2026-10-29. Files are the part of a web API where mistakes become
security incidents: a client chooses the file name, the declared type and the size, and every one of them is untrusted.
The page covers the upload side with multer 2 (the official Express middleware for multipart/form-data) and with
busboy (the parser underneath it), the rules for file names and storage locations, content verification, the download
side with res.download, res.attachment and res.sendFile, streaming, and object storage in the cloud. The running
example is the cover-image feature of the Bookshelf API that grows across this section. The examples use ESM and the
node: prefix for built-in modules, and contain placeholders only, never real secrets. Body parsers for JSON and forms
are in Body Parsing; the validation of the text fields that travel next to a
file is in Validation and Input Handling.
Why uploads need their own middleware
express.json() and express.urlencoded() read the whole body, decode it and put the result in req.body. A file
upload uses a different encoding, multipart/form-data, in which the body is a sequence of parts separated by a
boundary string, each with its own headers (field name, optional file name, optional content type). Neither built-in
parser understands it, so req.body stays undefined in Express 5 when a form is posted as multipart and no multipart
middleware is installed. The browser sets the correct Content-Type header, including the boundary, only when the form
declares enctype="multipart/form-data" (or when FormData is used with fetch; never set that header by hand, or
the boundary is lost). A frequent error in older tutorials is a misspelt enctype such as multipart/formdata: the
browser then falls back to URL-encoded data and the file is silently never sent.
Large files must not be buffered whole before being processed. A multipart parser therefore reads the stream incrementally and hands each file part to a destination (disk, memory or your own writable stream) while enforcing limits. Two packages do that in the Express ecosystem:
| Package | Role |
|---|---|
|
Express middleware maintained under the expressjs organisation. Wraps busboy, populates |
|
Low-level streaming parser used by multer. It emits events for each field and file and lets you pipe the file stream anywhere, which is the right tool when the destination is an object store or a processing pipeline. |
multer 2
Install it with npm install multer. Multer 2 is the current major version and the only one that receives security
fixes: the 1.x line carried denial-of-service vulnerabilities in its handling of malformed multipart requests, which
were fixed in the 2.0 release in 2025. Upgrading from 1.x needs no code changes for typical usage; check the project’s
release notes for the supported Node.js range before pinning it. Multer processes only requests whose content type is
multipart/form-data; any other request passes through untouched, so a route that also accepts JSON must declare its
own JSON parser.
Multer must be placed on the route that needs it, not globally, because every global instance would accept uploads on every endpoint, including the ones that were never designed to receive files. The configuration object creates a reusable instance, and the instance exposes five methods that decide which file fields are accepted:
| Method | Accepts | Result on the request |
|---|---|---|
|
one file in the field |
|
|
up to five files in the field |
|
|
several named file fields, each with its own maximum |
|
|
only text fields; any file part is rejected |
|
|
any file in any field |
|
upload.any() accepts files under any field name and should be avoided on public endpoints: the explicit methods
turn an unexpected field into a LIMIT_UNEXPECTED_FILE error instead of accepting it. The text fields of the same form
arrive in req.body, as strings, and the multipart parser populates them before the handler runs. The fields must be
validated like any other input.
The file object
Each uploaded file is described by an object whose properties depend on the storage engine:
| Property | Meaning |
|---|---|
|
name of the form field |
|
name on the client’s computer; fully attacker controlled |
|
transfer encoding reported by the client |
|
content type reported by the client; fully attacker controlled |
|
size in bytes, counted by the parser |
|
disk storage only: directory, name inside it and full path |
|
memory storage only: the whole file in a |
Only size is measured by the server. Everything else is a claim from the client.
Storage engines
Multer ships two engines. A third option is a custom engine (an object with _handleFile and _removeFile), which is
how packages for S3 or other stores integrate; for new code, a busboy-based handler or a presigned upload (below) is
usually clearer.
Disk storage
multer.diskStorage() writes each file to a directory. Both options receive the request, the file object and a
callback, so the destination and the file name can depend on the request:
// upload-disk.mjs
import { randomUUID } from 'node:crypto';
import { mkdir } from 'node:fs/promises';
import path from 'node:path';
import express from 'express';
import multer from 'multer';
// Outside any directory served by express.static
const UPLOAD_DIR = path.resolve(process.env.UPLOAD_DIR ?? './storage/covers');
await mkdir(UPLOAD_DIR, { recursive: true });
const ALLOWED_EXTENSIONS = new Map([
['image/jpeg', '.jpg'],
['image/png', '.png'],
['image/webp', '.webp'],
]);
const storage = multer.diskStorage({
destination(req, file, cb) {
cb(null, UPLOAD_DIR);
},
filename(req, file, cb) {
// The stored name never contains anything the client sent
const extension = ALLOWED_EXTENSIONS.get(file.mimetype) ?? '';
cb(null, `${randomUUID()}${extension}`);
},
});
export const uploadCover = multer({
storage,
limits: {
fileSize: 2 * 1024 * 1024, // 2 MiB per file
files: 1,
fields: 5,
fieldSize: 10 * 1024,
parts: 10,
},
fileFilter(req, file, cb) {
if (ALLOWED_EXTENSIONS.has(file.mimetype)) {
cb(null, true);
} else {
cb(new multer.MulterError('LIMIT_UNEXPECTED_FILE', file.fieldname));
}
},
}).single('cover');
const app = express();
app.post('/books/:id/cover', (req, res, next) => {
uploadCover(req, res, (err) => {
if (err) return next(err);
if (!req.file) {
return res.status(400).json({ error: 'A cover image is required' });
}
res.status(201).json({
id: req.params.id,
cover: req.file.filename,
size: req.file.size,
});
});
});
Source: Express middleware: multer
Points that the example illustrates and that cause real bugs:
-
When
destinationis a string passed tomulter({ dest })or to the storage, multer creates the directory; when it is a function, you must make sure the directory exists, as the example does withmkdir. -
The default
filename, when none is given, is a random 16-byte hexadecimal string without an extension. Most applications want an extension so the file can be served with the correct type; derive it from the verified type, not fromoriginalname. -
The
limitsobject is not optional hardening: without it, a single request can send an unlimited number of files of unlimited size and fill the disk. -
Multer does not delete files when a later validation fails. If the handler rejects the request after the upload (invalid text fields, unauthorised user), delete
req.file.pathyourself withfs.unlink.
Memory storage
multer.memoryStorage() keeps each file as a Buffer in req.file.buffer. It is convenient for small files that are
immediately processed (an avatar resized by an image library, a CSV parsed and discarded) and dangerous for anything
else, because every concurrent upload holds its whole file in the heap. Always combine it with a strict
limits.fileSize and a small files count, and never use it for video or archive uploads.
// upload-memory.mjs
import express from 'express';
import multer from 'multer';
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 512 * 1024, files: 1 },
});
const app = express();
app.post('/imports/books', upload.single('catalog'), (req, res) => {
if (!req.file) {
return res.status(400).json({ error: 'A catalog file is required' });
}
const text = req.file.buffer.toString('utf8');
const lines = text.split('\n').filter(Boolean);
res.json({ received: req.file.size, lines: lines.length });
});
Source: Express middleware: multer
Limits
All limits are optional in multer and unlimited unless stated, so a production configuration sets every one that applies. They are enforced while the stream is parsed, so an oversized request is rejected without being stored in full.
| Option | Meaning | Default |
|---|---|---|
|
maximum length of a field name, in bytes |
100 |
|
maximum size of a text field value, in bytes |
1 MiB |
|
maximum number of non-file fields |
unlimited |
|
maximum size of one file, in bytes |
unlimited |
|
maximum number of file fields |
unlimited |
|
maximum number of parts (fields plus files) |
unlimited |
|
maximum number of header key-value pairs per part |
2000 |
A breached limit makes multer call next with a MulterError, whose code property identifies the limit:
code |
Cause |
|---|---|
|
too many parts |
|
a file exceeded |
|
too many files |
|
field name too long |
|
field value too long |
|
too many text fields |
|
a file arrived in a field the route does not accept |
These are client errors and should become 4xx responses. A MulterError has no status property, so without a mapping
the default error handler would answer 500. The mapping belongs in the central error handler that is described in
Error Handling:
// upload-errors.mjs
import multer from 'multer';
const STATUS_BY_CODE = {
LIMIT_FILE_SIZE: 413,
LIMIT_PART_COUNT: 413,
LIMIT_FILE_COUNT: 413,
LIMIT_FIELD_COUNT: 413,
LIMIT_FIELD_KEY: 400,
LIMIT_FIELD_VALUE: 413,
LIMIT_UNEXPECTED_FILE: 400,
};
export function uploadErrorHandler(err, req, res, next) {
if (res.headersSent) return next(err);
if (err instanceof multer.MulterError) {
return res.status(STATUS_BY_CODE[err.code] ?? 400).json({
type: 'about:blank',
title: 'Upload rejected',
status: STATUS_BY_CODE[err.code] ?? 400,
detail: err.message,
field: err.field,
});
}
next(err);
}
Source: Express middleware: multer
Limits inside multer complement, but do not replace, limits further out. A reverse proxy normally has its own maximum
body size (for example client_max_body_size in nginx), and rejecting an oversized upload there is cheaper than in
Node.js. Check the Content-Length header early only as a hint: chunked requests have none, and multer’s counting is
the authoritative limit.
fileFilter
fileFilter(req, file, cb) runs for every file part before any byte is stored. Call cb(null, true) to accept,
cb(null, false) to skip silently, or cb(error) to abort the whole request. Because it runs while the request is
still being parsed, only the fields that arrived before the file are available in req.body, which is the reason to
put text fields before files when building a form. The filter sees file.mimetype and file.originalname, both
client-controlled, so it can reject obviously wrong uploads cheaply but can never prove a file is what it claims. The
real check happens after the file is stored, as described in MIME sniffing and content verification.
File names and path traversal
file.originalname is whatever the client put in the filename parameter of the Content-Disposition header of the
part. A hostile client sends ../../app/server.js, ..\\..\\windows\\system32\\x, an absolute path, a name with a null
byte, a name with control characters or a name that is 10,000 characters long. Two older patterns are the source of
most upload vulnerabilities:
-
Using
originalnameto build the destination path, for examplepath.join(publicDir, file.originalname). A name that starts with../escapes the directory, and a name that matches an existing file overwrites it. When the target is a directory that the application serves or executes, a path traversal becomes remote code execution or stored cross-site scripting. -
Storing the upload inside a directory served by
express.static. Anything stored there is served exactly as uploaded with a content type derived from its extension, so an uploaded.htmlor.svgruns script in your origin.
The rules that remove the class of bugs are simple:
-
Never use the client name on the file system. Generate the stored name with
crypto.randomUUID()or random bytes, and keep the original name only as metadata in the database, after sanitising it for display. -
If a client name must be kept for downloads, reduce it with
path.basename, strip control characters and path separators, limit its length and normalise Unicode.path.basenamealone is not enough on POSIX for a name that contains backslashes, which is why a whitelist is better than a blacklist. -
Choose the extension from your own allow-list, based on the verified content type.
-
Store uploads outside any directory that is served statically or executed, and serve them through a route that sets the headers (
Content-Type,Content-Disposition,X-Content-Type-Options: nosniff). The official security guidance also recommends keeping the application directory unwritable by the Node.js process whenever possible. -
When joining paths from any user-influenced segment, resolve the result and verify that it still starts with the base directory.
// safe-names.mjs
import path from 'node:path';
const BASE_DIR = path.resolve('./storage/covers');
/** Display-only sanitisation of a client-provided file name. */
export function sanitiseDisplayName(name) {
const lastSegment = String(name).split(/[\\/]/).pop() ?? '';
const cleaned = lastSegment
.normalize('NFKC')
.replace(/[\u0000-\u001f\u007f"<>|:*?]/g, '')
.replace(/^\.+/, '')
.trim();
return cleaned.slice(0, 120) || 'file';
}
/** Resolve a stored name inside the base directory or throw. */
export function resolveInside(baseDir, storedName) {
const resolved = path.resolve(baseDir, storedName);
if (!resolved.startsWith(baseDir + path.sep)) {
const err = new Error('Invalid file path');
err.status = 400;
err.expose = true;
throw err;
}
return resolved;
}
console.log(sanitiseDisplayName('../../etc/passwd')); // passwd
console.log(resolveInside(BASE_DIR, 'a1b2c3.png'));
The example above is a defence in depth. The strongest defence is the first rule: when nothing from the client reaches the file system, there is no path to traverse.
MIME sniffing and content verification
The Content-Type of a part and the extension of its name are both claims. A file named cover.png with the type
image/png can contain an HTML page, a script or an executable. Two separate problems follow:
-
The server stores something other than what the application expects (a PHP-style upload attack).
-
A browser later guesses the type of the response when the headers are missing or ambiguous (MIME sniffing) and runs content as script. Serving user content with
X-Content-Type-Options: nosniffand a correctContent-Typeprevents the second problem.
Verification means reading the first bytes of the stored file (the magic bytes, such as 89 50 4E 47 for PNG or
FF D8 FF for JPEG) and comparing the detected type to the allow-list. The file-type package does that detection, and
the application then renames or deletes the file accordingly:
// verify-content.mjs
import { unlink } from 'node:fs/promises';
import { fileTypeFromFile } from 'file-type';
const ALLOWED = new Set(['image/jpeg', 'image/png', 'image/webp']);
/**
* Express middleware, placed after multer: rejects and deletes the
* stored file when its real type is not an allowed image type.
*/
export async function verifyUploadedImage(req, res, next) {
if (!req.file) return next();
const detected = await fileTypeFromFile(req.file.path);
if (!detected || !ALLOWED.has(detected.mime)) {
await unlink(req.file.path).catch(() => {});
const err = new Error('The file is not a supported image');
err.status = 415;
err.expose = true;
return next(err);
}
req.file.verifiedMime = detected.mime;
next();
}
Source: sindresorhus/file-type
Because Express 5 forwards the rejection of an async middleware to the error handlers, the function needs no
try/catch around its own await calls. Magic bytes prove the container format, not the safety of what is inside:
a valid PNG can still be a decompression bomb, and image libraries have had memory-corruption bugs. Re-encoding images
with a maintained library (resizing to a fixed size also strips metadata such as GPS positions) is a stronger defence
than inspecting headers, and antivirus scanning is the usual extra step for documents. SVG deserves a special mention:
it is XML that can contain script, so either refuse it or serve it only with Content-Disposition: attachment from a
separate origin.
Full request lifecycle for an upload, from the client to storage and back:
| Stage | Check |
|---|---|
Reverse proxy |
maximum body size and request timeout |
Authentication |
only signed-in users upload (before multer, so unauthenticated requests are not parsed) |
multer |
file size, number of files and fields, number of parts |
|
early reject by the declared type and field name |
Storage |
random name outside the public directory |
Verification |
magic bytes, re-encoding, scanning |
Response |
store only the generated name; never echo the client path |
Authentication before multer is the stage that is most often forgotten: any middleware ahead of the upload route that rejects the request prevents the parser from consuming the body. This order is also why authenticated upload routes should run their guard first, as covered in Authentication and Authorization.
Streaming uploads with busboy
Multer is built on busboy, a streaming multipart parser with no dependency on Express. Using it directly is the right
choice when the file should never touch the local disk: it can be piped straight to object storage, through a hash
function or into a virus scanner. The parser is created with the request headers and is itself a writable stream that the
request is piped into. Each file part is exposed as a readable stream that must be consumed (piped somewhere or
resumed), otherwise the parser stalls.
// upload-busboy.mjs
import { createWriteStream } from 'node:fs';
import { mkdir, unlink } from 'node:fs/promises';
import { randomUUID } from 'node:crypto';
import path from 'node:path';
import { pipeline } from 'node:stream/promises';
import busboy from 'busboy';
import express from 'express';
const UPLOAD_DIR = path.resolve('./storage/manuscripts');
await mkdir(UPLOAD_DIR, { recursive: true });
const app = express();
app.post('/manuscripts', (req, res, next) => {
if (!req.is('multipart/form-data')) {
return res.status(415).json({ error: 'Send multipart/form-data' });
}
const bb = busboy({
headers: req.headers,
limits: { files: 1, fields: 5, fileSize: 20 * 1024 * 1024 },
});
const fields = {};
const saved = [];
const tasks = [];
bb.on('field', (name, value) => {
fields[name] = value;
});
bb.on('file', (name, file, info) => {
const storedName = randomUUID();
const target = path.join(UPLOAD_DIR, storedName);
let truncated = false;
file.on('limit', () => {
truncated = true;
});
const task = pipeline(file, createWriteStream(target)).then(async () => {
if (truncated) {
await unlink(target);
const err = new Error('File too large');
err.status = 413;
err.expose = true;
throw err;
}
saved.push({ field: name, storedName, clientName: info.filename });
});
tasks.push(task);
});
bb.on('error', next);
bb.on('close', async () => {
try {
await Promise.all(tasks);
res.status(201).json({ fields, saved });
} catch (err) {
next(err);
}
});
req.pipe(bb);
});
Source: mscdex/busboy
The points to know when using busboy directly:
-
The
fileevent receives the field name, the file stream and aninfoobject (filename,encoding,mimeType). When thefileSizelimit is hit, the stream stops emitting data, emitslimitand itstruncatedproperty becomes true: the bytes already written are an incomplete file and must be discarded. -
The
fieldevent receives the name, the value and aninfoobject; fields and files can interleave in any order. -
Busboy does not set limits for you. Pass
limitsexplicitly, with the same keys as multer (fieldNameSize,fieldSize,fields,fileSize,files,parts,headerPairs). -
If an error occurs midway, destroy the open streams and remove partial files; the example removes the file only when the limit is hit and should be completed with clean-up on the
errorevent in production. -
The parser decodes file names as
latin1unless the optiondefParamCharset: 'utf8'is set, which matters for names with accents; since the stored name is generated anyway, this affects only the display name. -
A
multipart/form-datarequest must not be passed throughexpress.json()or any other body parser first, because the stream would already be consumed.
Sending files to the client
The response object has three methods for files, and the differences between them are about who chooses the name, the headers and the safety checks:
| Method | Sets | Use for |
|---|---|---|
|
|
a file the browser should display or process (an image, a PDF shown inline) |
|
everything |
a file the browser should save |
|
|
a response that you stream or write yourself and that should be saved |
All three delegate the transfer to the send package, which provides conditional requests (If-None-Match,
If-Modified-Since), byte ranges and efficient streaming. This is the main reason to prefer them to
a manual pipe from createReadStream to the response.
res.sendFile and the root option
res.sendFile requires an absolute path or a root option, and it throws an error otherwise. The root option is the
secure way to serve a name that came from the request, because the send layer then refuses any path that escapes the
root, answering with 403 or 404 instead of leaking a file:
// download-routes.mjs
import path from 'node:path';
import express from 'express';
const STORAGE_ROOT = path.resolve('./storage/covers');
const app = express();
// GET /covers/<generated name>
app.get('/covers/:name', (req, res, next) => {
res.sendFile(
req.params.name,
{
root: STORAGE_ROOT,
dotfiles: 'deny',
maxAge: '1d',
headers: { 'X-Content-Type-Options': 'nosniff' },
},
(err) => {
if (err) next(err);
},
);
});
// GET /books/:id/manuscript: always saved, never shown inline
app.get('/books/:id/manuscript', (req, res, next) => {
const stored = `${req.params.id}.pdf`;
res.download(
stored,
'bookshelf-manuscript.pdf',
{ root: path.resolve('./storage/manuscripts'), dotfiles: 'deny' },
(err) => {
if (err && !res.headersSent) next(err);
},
);
});
Source: Express API: Response
Recommendations drawn from the example:
-
Always handle the callback. A missing file produces an error (
ENOENTbecomes a 404 witherr.status), and a client that aborts a download produces an error after the headers were sent, which must not trigger a second response: checkres.headersSentas shown in Error Handling. -
Pass
dotfiles: 'deny'or'ignore'explicitly when the name is influenced by the client. Dotfile handling is also the reason.well-knownneeds a special case inexpress.static. -
Even with
root, validate the identifier (a UUID, a numeric id) before using it as a name. Therootcheck is the last line of defence, not the first. -
Do not use
res.sendFilewith a path built fromreq.queryorreq.paramsand noroot; it is the Node.js equivalent of the classic local-file-inclusion bug.
res.download and Content-Disposition
res.download sets Content-Disposition: attachment with the file name you pass as the second argument, and falls back
to the base name of the path when you pass none. It uses the content-disposition package, which writes a name that is
a valid HTTP token (such as report.pdf) unquoted, quotes a name with spaces or other separators, and for a name with
non-ASCII characters adds an RFC 6266 / RFC 8187 filename* parameter next to a quoted ASCII fallback in which each
non-ASCII character becomes ?, so a name such as Résumé.pdf reaches the browser intact (this is the 5.3.0
behaviour, described below). Quotes and control characters in the name are escaped by that package; the file name
argument is still a response header, so never pass an unsanitised client name that contains new lines through any
other header builder.
The options object takes the same keys as res.sendFile (maxAge, root, dotfiles, headers, lastModified,
cacheControl, immutable, acceptRanges), and the optional headers can add X-Content-Type-Options or a stricter
Cache-Control. Responses to authenticated downloads should usually carry Cache-Control: private so that shared
caches do not store them.
res.attachment() is the lower-level form: it only sets the header and the content type, and you write the body:
// attachment-csv.mjs
import express from 'express';
const app = express();
app.get('/export/books.csv', (req, res) => {
res.attachment('books.csv');
res.write('id,title,author\n');
res.write('1,Dune,Frank Herbert\n');
res.end();
});
Source: Express API: Response
Express 5.3.0 upgraded content-disposition to ^2.0.1, which changes the header that res.download, res.attachment
and res.sendFile emit: a file name that is a valid HTTP token is no longer wrapped in quotes. Both forms mean the same
thing under RFC 6266, so browsers save the same file, but a test that asserts the exact header bytes must be updated:
# Express 5.2 and earlier: res.download(path, 'report.pdf') or res.attachment('books.csv')
Content-Disposition: attachment; filename="report.pdf"
Content-Disposition: attachment; filename="books.csv"
# Express 5.3.0 and later
Content-Disposition: attachment; filename=report.pdf
Content-Disposition: attachment; filename=books.csv
Names that need quoting ("my report.pdf") keep their quotes. In a Supertest assertion, either expect the new form or
parse the header with the parse function of content-disposition and compare the filename parameter, which works on both lines.
Source: expressjs/express: History.md
Streaming downloads
When the content is not a static file (a generated report, a database export, a file in object storage), stream it
instead of building it in memory. Use stream.pipeline so that errors and client disconnects destroy both ends and do
not leak file descriptors:
// stream-download.mjs
import { createReadStream } from 'node:fs';
import { stat } from 'node:fs/promises';
import path from 'node:path';
import { pipeline } from 'node:stream/promises';
import express from 'express';
const ROOT = path.resolve('./storage/archives');
const app = express();
app.get('/archives/:name', async (req, res) => {
const file = path.resolve(ROOT, req.params.name);
if (!file.startsWith(ROOT + path.sep)) {
return res.status(400).json({ error: 'Invalid name' });
}
const info = await stat(file); // rejection reaches the error handler (Express 5)
res.set({
'Content-Type': 'application/octet-stream',
'Content-Length': String(info.size),
'X-Content-Type-Options': 'nosniff',
});
res.attachment(path.basename(file));
try {
await pipeline(createReadStream(file), res);
} catch (err) {
// The client went away or the disk failed: headers are gone, so just stop
if (!res.headersSent) throw err;
}
});
Source: Node.js: stream.pipeline
A hand-written stream does not implement conditional requests or byte ranges: a browser that wants to resume a large
download or seek in a video sends Range, and only res.sendFile and res.download answer with 206 Partial Content.
For generated content, set Transfer-Encoding: chunked implicitly by omitting Content-Length, and apply
backpressure by using pipeline rather than calling res.write in a loop. The Node.js side of streams, including
backpressure and Readable.toWeb, is in
Implementing Custom Streams.
Cloud object storage
Local disks do not survive container restarts and cannot be shared by several instances behind a load balancer, so production applications usually store uploads in an object store (Amazon S3, Google Cloud Storage, Azure Blob Storage or an S3-compatible service). There are two designs:
| Design | How it works | Trade-off |
|---|---|---|
Proxy through Express |
The client uploads to Express, which streams the file to the store (busboy or multer with a custom engine). |
Express can authenticate, validate and scan every byte, but the traffic and the open connections cost server capacity. |
Presigned URL |
Express authenticates the user, validates the metadata and returns a short-lived signed URL; the client sends the file directly to the store. |
The API server handles no file bytes and scales independently; verification happens afterwards (a storage event, or a confirmation call). |
The proxy design with the AWS SDK v3 pipes a busboy file stream into a multipart upload, which keeps the memory constant whatever the size:
// upload-to-s3.mjs
import { randomUUID } from 'node:crypto';
import { S3Client } from '@aws-sdk/client-s3';
import { Upload } from '@aws-sdk/lib-storage';
import busboy from 'busboy';
import express from 'express';
// Region and credentials come from the environment or the runtime role
const s3 = new S3Client({ region: process.env.AWS_REGION });
const BUCKET = process.env.UPLOADS_BUCKET; // placeholder: your bucket name
const app = express();
app.post('/covers', (req, res, next) => {
const bb = busboy({ headers: req.headers, limits: { files: 1, fileSize: 5 * 1024 * 1024 } });
const uploads = [];
bb.on('file', (name, file, info) => {
const key = `covers/${randomUUID()}`;
const upload = new Upload({
client: s3,
params: {
Bucket: BUCKET,
Key: key,
Body: file,
ContentType: info.mimeType,
ContentDisposition: 'attachment',
},
});
file.on('limit', () => upload.abort());
uploads.push(upload.done().then(() => key));
});
bb.on('error', next);
bb.on('close', async () => {
try {
res.status(201).json({ keys: await Promise.all(uploads) });
} catch (err) {
next(err);
}
});
req.pipe(bb);
});
The presigned design needs only a signature route; the file never crosses the API:
// presign-upload.mjs
import { randomUUID } from 'node:crypto';
import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import express from 'express';
const s3 = new S3Client({ region: process.env.AWS_REGION });
const BUCKET = process.env.UPLOADS_BUCKET;
const ALLOWED = new Set(['image/jpeg', 'image/png', 'image/webp']);
const app = express();
app.use(express.json());
app.post('/covers/presign', async (req, res) => {
const { contentType } = req.body ?? {};
if (!ALLOWED.has(contentType)) {
return res.status(400).json({ error: 'Unsupported content type' });
}
const key = `covers/${randomUUID()}`;
const url = await getSignedUrl(
s3,
new PutObjectCommand({ Bucket: BUCKET, Key: key, ContentType: contentType }),
{ expiresIn: 300 },
);
res.status(201).json({ key, url, expiresIn: 300 });
});
Whichever design is used:
-
Keep the bucket private and download through short-lived signed URLs or an authenticated Express route that streams the object. A public-read bucket is the cloud version of storing uploads in
public/. -
Give the application role only the permissions it needs (put on one prefix, get on the same prefix) and never place access keys in the repository; use the platform’s role or a secret manager.
-
Presigned
PUTURLs cannot enforce a maximum size by themselves; use presignedPOSTpolicies (@aws-sdk/s3-presigned-post) with acontent-length-rangecondition when the size matters, or verify after the fact. -
Store the object key and the original display name in the database, and delete the object when the record is deleted.
-
Cloud storage replaced the image hosting services that older tutorials used for uploaded images; several of those services have since changed their free plans or shut down, so avoid depending on a particular free tier.
Bookshelf: cover images
Putting the pieces together, the cover route of the Bookshelf API authenticates first, then parses with strict limits, verifies the content, and records only generated names. The router wires the middleware from this page:
// covers-router.mjs
import express from 'express';
import { uploadCover } from './upload-disk.mjs';
import { verifyUploadedImage } from './verify-content.mjs';
export const covers = express.Router();
function requireUser(req, res, next) {
// Placeholder: replace with the session or JWT check of the application
if (!req.get('authorization')) {
return res.status(401).json({ error: 'Authentication required' });
}
next();
}
function parseCover(req, res, next) {
uploadCover(req, res, next);
}
covers.post('/books/:id/cover', requireUser, parseCover, verifyUploadedImage, (req, res) => {
res.status(201).json({ id: req.params.id, cover: req.file.filename, type: req.file.verifiedMime });
});
Source: Express middleware: multer
What changed recently
-
Multer 2 is the maintained line (the current npm version is 2.5.0). The 1.x line, which most tutorials install, had denial-of-service vulnerabilities that were fixed only in 2.0.
-
Express 5 forwards rejected promises from async middleware, so the verification and download handlers above need no wrapper such as
express-async-errors. -
connect-multiparty, a middleware that older tutorials used for uploads, is deprecated (blog post of 2025-05-16); use multer or busboy instead. -
res.sendFileandres.downloadin Express 5 usesend1.x, and the removedres.sendfile(lower case) of Express 3 and 4 must be replaced withres.sendFile. -
Express 5.3.0 moved to
content-disposition2, soContent-Dispositionno longer quotes file names that are valid HTTP tokens (filename=report.pdfinstead offilename="report.pdf"); tests that compare the exact header must be updated, as shown in res.download and Content-Disposition. -
Express 5 requires Node.js 18 or later, so streams,
node:stream/promises,fetchandcrypto.randomUUID()can be used without polyfills.
Where the books are outdated
The books consulted for this section show uploads in the style of Express 4 and Node.js 6. The points below are about what changed, not a reproduction of their listings:
| Older practice | Current practice |
|---|---|
Form posted with a misspelt multipart enctype, so no file ever arrives |
Declare |
The stored path built from the client’s |
Path traversal and overwrite. Generate names, store outside |
Trusting |
Client claim only; verify magic bytes and re-encode images, see MIME sniffing and content verification. |
|
Multer 2 with explicit |
Callback-style handlers with |
Express 5 promise forwarding, a central error handler and |
Uploaded images sent to a third-party image hosting account |
Private object storage with presigned URLs or a streamed upload, see Cloud object storage. |
|
Removed in Express 5: |
|
Still available (see Official Middleware Modules), but browser forms can use |
Common pitfalls
-
Registering
upload.single()globally withapp.use, so every route parses and stores files. -
Putting body-parser or logging middleware that reads the stream before multer, leaving multer an empty stream.
-
Forgetting that
req.bodytext fields in a multipart request are all strings and are available only after multer ran. -
Checking
req.filein a route that was not given a file:req.fileisundefinedwhen the request carried no file, and that is a 400, not a 500. -
Leaving uploaded files on disk when a later validation fails.
-
Using
MemoryStoragewith large or unbounded files, which exhausts the heap under concurrency. -
Serving uploads from
express.staticwith their original extensions. -
Setting the
Content-Typeheader manually infetchoraxiosforFormData, which drops the multipart boundary. -
Streaming a download without handling the client abort, which leaves open file descriptors; use
pipeline.