Secure image upload API with Node.js, Express, and Multer
An upload should become downloadable only after its bytes pass your checks. This example receives one JPEG or PNG, keeps it in quarantine while ClamAV scans it, decodes it with Sharp, and then makes the original file available through an Express download route. Rejected files stay out of that route.
The result is a local image upload API for developers testing a server-side upload pipeline. It
listens only on 127.0.0.1, accepts files up to 5 MiB, and preserves accepted bytes, including image
metadata. It has no user accounts or per-file authorization. Keep it local until your application
provides those controls.
Setting up the Node.js environment
Use Linux with Node.js 26.8.1, Yarn 4.12.0 through Corepack, Docker, and cURL. These are the versions and platform used here. Reserve 4 GiB of memory for the ClamAV container in addition to the memory needed by Node.js. The Docker daemon must run on this machine so it can mount the quarantine directory.
Create a new project. The && chain stops if the directory already exists or any setup step fails;
choose another project name rather than removing an existing directory. Keep the generated lockfile.
mkdir image-upload-api &&
cd image-upload-api &&
printf '%s\n' '{"name":"image-upload-api","private":true,"type":"module","packageManager":"yarn@4.12.0"}' > package.json &&
touch yarn.lock &&
corepack yarn config set nodeLinker node-modules &&
corepack yarn add --exact express@5.2.1 multer@2.4.0 sharp@0.35.4 express-rate-limit@8.7.0 &&
corepack yarn add --dev --exact @types/express@5.0.6 @types/multer@2.2.0 @types/node@26.6.2
Use Multer 2.4.0 or a later patched release. Versions 2.2.0 through 2.3.0 can leave orphaned files when a client disconnects before an asynchronous storage callback assigns the path. The maintainer advisory identifies 2.4.0 as the fix. Application error handling alone does not repair that library race.
Virus scanning for uploaded files
From the new project directory, start a private scanner. This pinned ClamAV 1.5.4 image includes a
signature database. The container has no network access or published ports; only the quarantine
folder is mounted, read-only. The application invokes clamdscan inside it through Docker.
mkdir -m 700 quarantine accepted &&
docker run --detach --rm --name image-upload-clamav \
--memory 4g --network none --env CLAMAV_NO_FRESHCLAMD=true \
--mount "type=bind,source=$PWD/quarantine,target=/scan,readonly" \
clamav/clamav@sha256:0e31ce089574268aefa0b543767d66b70240ab51ed49eec53e07f18d5629d817
Wait for the daemon to load its database before starting the API:
docker exec image-upload-clamav clamdscan --ping 120:1 &&
docker exec image-upload-clamav clamdscan --version
The pinned image reported database 28129 dated September 20, 2026, four days old when tested. Disabling FreshClam makes this an offline demonstration, not a continuously updated scanning service. A clean verdict means that these signatures did not detect malware; it does not certify a file as harmless. For a deployed service, maintain current signatures and monitor scanner health. The official Docker guide explains database updates and memory requirements.
Creating the basic API endpoint structure
Save the following complete program as app.ts in the project directory. Node runs this TypeScript
file directly. Start it from that same directory so the host paths match the scanner mount.
The scanner accepts only a successful, explicit clean result for the file being checked. A missing
container, daemon error, timeout, or unexpected response rejects the upload. --fdpass lets
clamdscan open the private file and pass its descriptor to the daemon over its Unix socket; see
the ClamAV scanning documentation.
import { execFile } from 'node:child_process'
import { randomBytes } from 'node:crypto'
import { link, mkdir, rm, writeFile } from 'node:fs/promises'
import { resolve } from 'node:path'
import express, { type ErrorRequestHandler } from 'express'
import { rateLimit } from 'express-rate-limit'
import multer from 'multer'
import sharp from 'sharp'
process.umask(0o077)
const quarantine = resolve('quarantine')
const accepted = resolve('accepted')
const container = process.env.CLAMAV_CONTAINER ?? 'image-upload-clamav'
const port = Number(process.env.PORT ?? 3000)
const app = express()
let activeUploads = 0
class UploadError extends Error {
status: number
constructor(status: number, message: string) {
super(message)
this.status = status
}
}
function scanFile(filename: string): Promise<void> {
const path = `/scan/${filename}`
return new Promise((resolveScan, reject) => {
execFile('docker', ['exec', container, 'clamdscan', '--fdpass', '--no-summary', path],
{ timeout: 30_000, maxBuffer: 64 * 1024 }, (error, stdout) => {
const result = stdout.trim()
if (!error && result === `${path}: OK`) return resolveScan()
if (error?.code === 1 && result.startsWith(`${path}: `) && result.endsWith(' FOUND')) {
return reject(new UploadError(422, 'Malware detected.'))
}
reject(new UploadError(503, 'Scanner unavailable or scan inconclusive.'))
})
})
}
const receive = multer({
storage: multer.diskStorage({
destination: quarantine,
filename(_req, _file, callback) {
randomBytes(16, (error, bytes) => {
if (error) return callback(error, '')
callback(null, bytes.toString('hex'))
})
},
}),
limits: { fileSize: 5 * 1024 * 1024, files: 1, fields: 0, parts: 2 },
fileFilter(_req, file, callback) {
if (file.mimetype !== 'image/jpeg' && file.mimetype !== 'image/png') {
return callback(new UploadError(415, 'Send a JPEG or PNG image.'))
}
callback(null, true)
},
}).single('image')
const uploadLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
limit: 10,
standardHeaders: 'draft-8',
legacyHeaders: false,
message: { error: 'Upload limit reached. Try again after 15 minutes.' },
})
app.disable('x-powered-by')
app.use((_req, res, next) => {
res.set({ 'X-Content-Type-Options': 'nosniff', 'Cache-Control': 'no-store' })
next()
})
app.post('/upload', uploadLimiter, async (req, res) => {
if (activeUploads >= 2) throw new UploadError(503, 'Two uploads are already processing.')
activeUploads += 1
// An absolute deadline also covers a client that keeps sending tiny chunks.
const deadline = setTimeout(() => res.destroy(), 60_000)
let publishedPath: string | undefined
let committed = false
try {
await new Promise<void>((done, reject) => {
receive(req, res, (error: unknown) => error ? reject(error) : done())
})
if (!req.file) throw new UploadError(400, 'Use the multipart file field named image.')
await scanFile(req.file.filename)
const decoder = sharp(req.file.path, { limitInputPixels: 12_000_000, failOn: 'warning' })
const metadata = await decoder.metadata().catch(() => {
throw new UploadError(415, 'Image headers are invalid or exceed 12 megapixels.')
})
if (metadata.format !== 'jpeg' && metadata.format !== 'png') {
throw new UploadError(415, 'Only JPEG and PNG files are accepted.')
}
const mime = metadata.format === 'jpeg' ? 'image/jpeg' : 'image/png'
if (mime !== req.file.mimetype) throw new UploadError(415, 'Image bytes and MIME type differ.')
await decoder.raw().toBuffer().catch(() => {
throw new UploadError(415, 'Image pixels could not be decoded.')
})
if (req.aborted || res.destroyed) return
const filename = `${req.file.filename}.${metadata.format === 'jpeg' ? 'jpg' : 'png'}`
const destination = resolve(accepted, filename)
// A hard link publishes the complete file atomically and refuses an existing name.
await link(req.file.path, destination)
publishedPath = destination
if (req.aborted || res.destroyed) return
await rm(req.file.path)
res.status(201).json({ filename, size: req.file.size, url: `/images/${filename}` })
committed = true
} finally {
clearTimeout(deadline)
try {
// Multer may already have removed the file and cleared its path after a later part fails.
if (req.file?.path) await rm(req.file.path, { force: true })
if (publishedPath && !committed) await rm(publishedPath, { force: true })
} finally {
activeUploads -= 1
}
}
})
app.get('/images/:filename', (req, res, next) => {
const { filename } = req.params
if (!/^[a-f0-9]{32}\.(jpg|png)$/.test(filename)) {
throw new UploadError(404, 'Image not found.')
}
res.download(resolve(accepted, filename), filename, (error) => {
if (!error) return
if (res.headersSent) return next(error)
next(new UploadError(404, 'Image not found.'))
})
})
const handleError: ErrorRequestHandler = (error: unknown, _req, res, next) => {
if (res.headersSent) return next(error)
if (res.destroyed) return
const status = error instanceof UploadError ? error.status
: error instanceof multer.MulterError ? (error.code === 'LIMIT_FILE_SIZE' ? 413 : 400)
: 500
const message = error instanceof UploadError ? error.message
: status === 413 ? 'File exceeds 5 MiB.' : 'Upload could not be processed.'
console.error('Request failed', { status })
res.status(status).json({ error: message })
}
app.use(handleError)
async function main(): Promise<void> {
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error('PORT must be an integer from 1 to 65535.')
}
await mkdir(accepted, { recursive: true, mode: 0o700 })
const probe = `probe-${randomBytes(16).toString('hex')}`
await writeFile(resolve(quarantine, probe), 'Scanner readiness check\n', { flag: 'wx' })
try {
await scanFile(probe)
} finally {
await rm(resolve(quarantine, probe), { force: true })
}
app.listen(port, '127.0.0.1', (error?: Error) => {
if (error) {
console.error('Cannot bind API port; choose an unused PORT.')
process.exitCode = 1
return
}
console.log(`Ready at http://127.0.0.1:${port}`)
})
}
main().catch((error: unknown) => {
console.error(error instanceof UploadError ? error.message
: 'Startup failed. Check PORT, directories, and the scanner mount.')
process.exitCode = 1
})
Check bytes before publishing them
Multer handles the multipart envelope and the 5 MiB limit. Its MIME filter is an early check of a
client-supplied label. Sharp then checks the detected format against that label and decodes the
pixels; a text file named photo.png does not pass. The original filename never becomes a storage
path. Downloads get an extension derived from the detected format.
Sharp’s metadata() reads headers without decoding
pixel data, which is why the example also calls raw().toBuffer(). Sharp decodes the default image;
this does not validate every animation frame in an APNG file. The decoded buffer is discarded, so
the stored file remains byte-for-byte identical to the upload. The
12-million-pixel limit and strict decoding reduce
resource exposure. They are not a sandbox for the native decoder. Keep Sharp and its native
libraries patched. GIF, SVG, and other formats are outside this example’s accepted inputs.
Two uploads can be receiving, scanning, or decoding at once. Further uploads receive 503; the
60-second deadline closes the client connection; native processing already in progress finishes
before its slot is released. The IP limiter allows ten attempts per 15 minutes, including rejected
attempts. Its in-memory counters reset with
the process and are not shared across servers. Neither limit supplies an account quota or bounds
how many accepted files accumulate over time.
Start the API and interpret failures
Start the server in the foreground after the scanner is ready:
node app.ts
Wait for Ready at http://127.0.0.1:3000. Startup performs an actual scan through the mounted
quarantine directory before opening the port. If port 3000 is occupied, choose another with
PORT=3007 node app.ts and use that port in the cURL commands. Express 5 passes bind errors to the
app.listen callback; this program exits
unsuccessfully instead of printing a ready URL on that path.
| Status | Meaning |
|---|---|
201 | Scanned and decoded; the original bytes are available at the returned URL. |
400 | Missing file, unexpected field, or a Multer multipart limit other than file size. |
413 | File exceeds 5 MiB. |
415 | Unsupported MIME type, mismatched bytes, invalid image, or pixel limit failure. |
422 | ClamAV detected malware. |
429 | Too many upload attempts from this IP. |
503 | Scanner failure or two uploads already processing. |
500 | Unexpected parsing, filesystem, or server failure. |
On ordinary rejected requests the file is removed from quarantine. Patched Multer also handles aborted writes, including the asynchronous filename callback. A process crash or forced shutdown can still leave quarantine files; inspect and remove those only while the API is stopped. Once the server commits an accepted file, it remains stored even if the client loses the response.
Testing the API with cURL
Open another terminal in the project directory. Create a tiny, valid PNG without needing a sample
image download. This refuses to overwrite an existing sample.png:
node --input-type=module -e '
import { writeFile } from "node:fs/promises";
import sharp from "sharp";
const image = await sharp({ create: { width: 2, height: 2, channels: 3, background: "red" } }).png().toBuffer();
await writeFile("sample.png", image, { flag: "wx" });
'
Upload it, then download the URL from the JSON response. The shell’s noclobber setting makes this
block refuse existing result files. Use new filenames for another run. cmp prints nothing and
exits successfully when the downloaded bytes match the original.
(
set -euC
curl --fail-with-body --silent --show-error \
-F 'image=@sample.png;type=image/png' http://127.0.0.1:3000/upload > upload.json
image_url=$(node --input-type=module -e '
import { readFile } from "node:fs/promises";
const result = JSON.parse(await readFile("upload.json", "utf8"));
if (!/^\/images\/[a-f0-9]{32}\.(jpg|png)$/.test(result.url)) throw new Error("Invalid upload response");
console.log(result.url);
')
curl --fail --silent --show-error "http://127.0.0.1:3000$image_url" > downloaded.png
cmp sample.png downloaded.png
)
For a simple rejection check, send the project manifest while claiming it is a PNG:
curl --silent --show-error --write-out '\nHTTP %{http_code}\n' \
-F 'image=@package.json;filename=photo.png;type=image/png' http://127.0.0.1:3000/upload
Expect 415, no new accepted file, and an empty quarantine directory after the request. If you
stop the scanner with docker stop image-upload-clamav while the API is running, a valid image
instead receives 503. Restart it with the earlier docker run command, leaving the existing
project directories in place, and wait for readiness before retrying.
Decide what to retain and who can download it
The two directories live outside any static web root, on the same filesystem so publication can use a hard link. New files are private to the OS account running Node. Accepted files have random names and are never overwritten; uploading the same picture again creates another file. Stop the API with Ctrl+C and stop its scanner when finished. Both directories remain on disk for you to inspect or remove deliberately.
Error logs contain status codes without client filenames or scanner output. The download route
serves attachments with nosniff and no-store. It does not remove EXIF,
location data, trailing bytes, or other embedded content. Scan and decode success are narrower
claims than sanitization. If you need a normalized public image, add a separate re-encoding step and
verify that step’s metadata and format policy before exposing its output.
Before connecting this pipeline to a public application or private object store, add authentication, per-file authorization, storage quotas, and a retention policy. Random URLs and CORS do not provide ownership checks. The Docker command adapter is convenient for a local demonstration but gives the Node process access to the Docker daemon; use a dedicated scanner integration with narrower privileges in a deployed service. This tutorial does not configure or verify a cloud-storage path.
