File uploads in Node.js with Express
By Flavio Copes
Build secure file uploads in Node.js and Express with multipart forms, size limits, generated filenames, temporary storage, validation, and cleanup.
A file upload is more than saving req.files.photo to a folder.
The request can be huge. The filename comes from the client. The file contents might not match the declared type. Two uploads can use the same name. A disconnected client can leave temporary data behind.
In this guide we’ll build an upload endpoint with Node.js, Express, and express-fileupload. Then we’ll add the checks that make it safe to expose.
The browser side is covered separately in how to upload a file with Fetch.
How browser uploads work
HTML forms send files using the multipart/form-data content type:
<form action="/uploads" method="post" enctype="multipart/form-data">
<label>
Choose an image
<input type="file" name="photo" accept="image/jpeg,image/png">
</label>
<button>Upload</button>
</form>
The enctype is required. A normal URL-encoded form cannot carry the file bytes.
The browser creates a multipart boundary and uses it to separate fields and files inside the request body. Each file part contains headers followed by binary data.
Do not set the Content-Type header manually when sending a FormData object with fetch(). The browser adds the correct boundary:
const form = new FormData()
form.append('photo', fileInput.files[0])
const response = await fetch('/uploads', {
method: 'POST',
body: form
})
Express does not parse multipart file data by itself. We need middleware that understands the format.
Install express-fileupload
Install Express and the middleware:
npm install express express-fileupload
Create a small application:
import express from 'express'
import fileUpload from 'express-fileupload'
const app = express()
app.use(fileUpload())
app.listen(3000, () => {
console.log('Server ready on http://localhost:3000')
})
After the middleware parses a multipart request, uploaded files are available on req.files.
The key matches the form field name. Our input uses name="photo", so the file is req.files.photo.
Build the smallest upload endpoint
Create the destination directory before starting the server:
mkdir uploads
Then add the route:
import path from 'node:path'
app.post('/uploads', async (req, res) => {
if (!req.files?.photo) {
res.status(400).json({ error: 'Choose a file' })
return
}
const photo = req.files.photo
const destination = path.join(process.cwd(), 'uploads', 'photo.jpg')
await photo.mv(destination)
res.status(201).json({ ok: true })
})
mv() moves the uploaded file to its final location. When you omit a callback, it returns a Promise.
This example proves the request flow, but it is not ready for public traffic. It overwrites the same file and performs no validation.
Let’s fix that.
Reject missing and multiple files
A field can contain one file or an array of files.
If this endpoint accepts exactly one image, reject arrays:
const photo = req.files?.photo
if (!photo || Array.isArray(photo)) {
res.status(400).json({ error: 'Upload exactly one photo' })
return
}
If you want multiple files, normalize the value to an array:
const uploaded = req.files?.photos
const photos = Array.isArray(uploaded) ? uploaded : [uploaded]
Check that uploaded exists before doing this.
Make the endpoint’s contract explicit. Quietly accepting the first file can hide a broken client.
Limit the upload size
Set the size limit in middleware, before your route receives the file:
app.use(fileUpload({
limits: {
fileSize: 5 * 1024 * 1024
},
abortOnLimit: true,
responseOnLimit: 'File is larger than 5 MB'
}))
abortOnLimit stops the upload when the file crosses the limit. The client receives a 413 Payload Too Large response.
Size limits protect memory, disk, bandwidth, and the work performed by later image or document processors.
Also set limits at the reverse proxy or hosting platform when possible. Rejecting an oversized body before it reaches Node saves more resources.
Do not trust photo.size as your only protection. That value is useful after parsing, but the server has already received the body.
Store large uploads in temporary files
By default, express-fileupload keeps file data in memory and exposes it as photo.data.
That is convenient for small files, but concurrent uploads can consume a lot of memory.
Use temporary files for larger workloads:
app.use(fileUpload({
useTempFiles: true,
tempFileDir: '/tmp/uploads',
createParentPath: true,
limits: {
fileSize: 20 * 1024 * 1024
},
abortOnLimit: true
}))
With this option, the middleware writes incoming data to disk. The uploaded object exposes tempFilePath, and mv() moves that file.
Choose a temporary directory writable only by the application. Make sure the filesystem has enough space and an operational cleanup policy.
For very large uploads, consider direct browser-to-object-storage uploads with short-lived signed URLs. Your Node.js server can authorize the upload without carrying every byte through its own memory and network connection.
Understand streaming and backpressure
An upload arrives as a stream of bytes. Good multipart parsers consume those bytes gradually instead of waiting for the complete request body.
Streaming keeps memory bounded, but it does not make storage unlimited. The destination can be slower than the network. When that happens, the parser and request stream need to pause until the destination catches up.
That pressure flowing backward through the pipeline is backpressure.
express-fileupload wraps Busboy and hides most of this machinery. Its temporary-file mode is the practical choice when you do not want complete uploads held in memory.
If you later replace the middleware with a lower-level streaming parser, do not ignore the return value of writable.write(). Wait for the drain event when it returns false, or use stream.pipeline() to connect the streams and propagate errors.
Also decide what happens when the client disconnects. Stop processing, close destination streams, and remove partial files. A request’s aborted event is a useful signal, but cleanup still needs to be safe when several error paths happen close together.
Streaming is a resource-control tool. Validation and authorization still apply.
Never use the client filename as the storage path
photo.name comes from the client. It is display information, not a safe identifier.
A name can contain awkward characters, collide with another upload, or attempt path traversal. Even a cleaned name can overwrite an existing file.
Generate your own storage name:
import { randomUUID } from 'node:crypto'
import path from 'node:path'
const extension = '.jpg'
const storedName = `${randomUUID()}${extension}`
const destination = path.join(process.cwd(), 'uploads', storedName)
Keep the original name separately in a database if the user needs to see it later.
The middleware has options such as safeFileNames and preserveExtension, documented in the official express-fileupload repository. They are useful defense-in-depth, but generated names are a stronger storage rule.
Validate the file type
The file object includes a MIME type:
console.log(photo.mimetype)
The browser supplies that value. An attacker can claim an executable is image/jpeg.
Use MIME type as an early filter, not proof:
const allowedTypes = new Set(['image/jpeg', 'image/png'])
if (!allowedTypes.has(photo.mimetype)) {
res.status(415).json({ error: 'Upload a JPEG or PNG image' })
return
}
Then inspect the actual bytes with a file-type detector or decode the image with the library that will process it. A valid JPEG or PNG has a recognizable binary signature and structure.
For images, decoding and re-encoding can remove unexpected metadata and prove the file is readable. Still apply pixel-dimension and memory limits. A compressed file can expand into a huge image.
For documents, use a parser designed for that format. Virus scanning can be another step for files later downloaded by other users.
Validation should follow the file’s real purpose, not only its extension.
Calculate a checksum when identity matters
A checksum gives the stored bytes a stable identity. It can detect corruption and help avoid storing the exact same content twice.
Calculate it while streaming when possible. Reading a large file again only to hash it doubles your disk work.
SHA-256 is a practical content checksum:
import { createHash } from 'node:crypto'
import { createReadStream } from 'node:fs'
const hash = createHash('sha256')
const input = createReadStream(photo.tempFilePath)
for await (const chunk of input) {
hash.update(chunk)
}
const checksum = hash.digest('hex')
A checksum does not prove a file is safe. It only identifies its bytes. Keep it alongside the upload metadata when you need integrity checks or deduplication.
A safer complete endpoint
This endpoint accepts one JPEG or PNG, limits its size, generates a name, and handles move errors:
import express from 'express'
import fileUpload from 'express-fileupload'
import { randomUUID } from 'node:crypto'
import path from 'node:path'
const app = express()
app.use(fileUpload({
useTempFiles: true,
tempFileDir: '/tmp/notes-api-uploads',
createParentPath: true,
limits: {
fileSize: 5 * 1024 * 1024
},
abortOnLimit: true
}))
app.post('/uploads', async (req, res) => {
const photo = req.files?.photo
if (!photo || Array.isArray(photo)) {
res.status(400).json({ error: 'Upload exactly one photo' })
return
}
const extensions = new Map([
['image/jpeg', '.jpg'],
['image/png', '.png']
])
const extension = extensions.get(photo.mimetype)
if (!extension) {
res.status(415).json({ error: 'Upload a JPEG or PNG image' })
return
}
const storedName = `${randomUUID()}${extension}`
const destination = path.join(process.cwd(), 'uploads', storedName)
try {
await photo.mv(destination)
res.status(201).json({ id: storedName })
} catch (error) {
console.error(error)
res.status(500).json({ error: 'Could not store the file' })
}
})
app.listen(3000)
The MIME check still needs byte-level validation for a public system. I kept it visible here so each step is easy to understand.
Do not serve uploads as executable content
User uploads should not live inside your source tree or a public directory that can execute scripts.
If you serve them from Express, send a controlled content type and consider forcing downloads:
app.get('/uploads/:id', async (req, res) => {
const file = await findUpload(req.params.id)
if (!file) {
res.sendStatus(404)
return
}
res.type(file.mimeType)
res.set('X-Content-Type-Options', 'nosniff')
res.sendFile(file.absolutePath)
})
Do not turn req.params.id directly into a path. Look up a server-generated identifier and retrieve the stored path you trust.
For private uploads, check authorization on every download. A hard-to-guess URL is not access control.
Object storage is usually a better home for durable uploads. Store the generated key, original filename, size, type, owner, and processing state in your database.
Handle partial work and cleanup
An upload can fail after the middleware created a temporary file. Validation can reject it. Moving to permanent storage can fail. A database write can fail after the file was stored.
Design cleanup for every stage.
A common workflow is:
- Receive the file in temporary storage.
- Validate its size and contents.
- Generate the permanent key.
- Store the file.
- Save its database record.
- Delete temporary data.
If step 5 fails, delete the permanent object or record it for a cleanup job. If processing happens in a queue, keep an explicit pending, ready, or failed state.
Do not assume the happy path always reaches your last line.
Protect the endpoint
File upload endpoints are expensive. Add the same protections you would add to any write operation, plus file-specific limits:
- authenticate the caller when uploads are private
- authorize ownership and quotas
- rate-limit requests
- limit file count and total bytes
- use CSRF protection for cookie-authenticated forms
- validate actual content
- generate storage names
- keep uploads outside executable directories
- set timeouts
- log a server-generated upload ID, not file contents or secrets
If uploads trigger CPU-heavy processing, move that work to a queue. Return an upload ID and let the client check the processing state.
Test the unhappy paths
Test more than a successful JPEG.
Send:
- no file
- two files in a single-file field
- an oversized file
- a fake MIME type
- a filename containing path separators
- two files with the same original name
- a request that disconnects early
- a file that fails permanent storage
Also test concurrent uploads. Memory and disk limits often look fine with one request and fail under ten.
How I would design it
For a small internal tool, I would use express-fileupload, strict size limits, generated names, and a private directory. It keeps the first version understandable.
For a public application with large files, I would let the browser upload directly to object storage using a short-lived signed request. The Node.js application would authorize the operation, record metadata, and process the result asynchronously.
I would never trust the original filename or MIME type. Those values are hints from the client.
The upload is complete only after validation, durable storage, metadata, and cleanup all agree.
Related posts about node: