How to scan file uploads for viruses in Node.js
When your application accepts file uploads from users — profile pictures, documents, attachments — those files can contain malware. A malicious user can upload a virus-infected PDF, a trojan disguised as a spreadsheet, or a script embedded in an image. If you store or serve those files without scanning, you put your infrastructure and other users at risk.
This guide shows you how to scan every uploaded file for malware before it touches your storage, using ClamAV (a free, open-source antivirus engine) and pompelmi (a minimal Node.js wrapper around it).
How it works
The flow is straightforward:
- The user submits a file through your upload form or API.
- Your server receives the file and writes it to a temporary location on disk.
- You call
scan(filePath)— pompelmi passes the file to ClamAV. - ClamAV checks the file against its virus database and returns a result.
- pompelmi returns a Verdict:
Verdict.Clean,Verdict.Malicious, orVerdict.ScanError. - You either move the file to permanent storage (clean) or delete it and reject the request (malicious or error).
Nothing leaves your server. No cloud API, no data sent to a third party.
ClamAV runs locally as a binary (clamscan) that pompelmi invokes
as a subprocess.
Install ClamAV and pompelmi
First, install ClamAV on your system:
macOSbrew install clamav && freshclamLinux (Debian / Ubuntu)
sudo apt-get install -y clamav && sudo freshclamWindows
choco install clamav -y
Then install pompelmi in your Node.js project:
npm install pompelmi
freshclam downloads the virus definition database. It can take a
few minutes on first run — the database is several hundred megabytes. You only
need to do this once (then keep it updated via a daily cron or service).
Your first scan
The pompelmi API has one main function: scan(filePath).
It returns a Promise that resolves to a Verdict Symbol.
const { scan, Verdict } = require('pompelmi');
async function checkFile(filePath) {
const verdict = await scan(filePath);
if (verdict === Verdict.Clean) {
console.log('File is safe.');
} else if (verdict === Verdict.Malicious) {
console.log('Malware detected! Reject this file.');
} else if (verdict === Verdict.ScanError) {
console.log('Scan could not complete — treat file as untrusted.');
}
}
checkFile('/tmp/somefile.pdf');
That's the entire API surface you need to know for most use cases.
scan() is async, returns a Symbol, and throws only if
something went wrong at the OS level (e.g. ClamAV not found).
What are Verdict Symbols?
pompelmi uses JavaScript Symbols for verdict values rather than strings.
This means you compare with === just like strings, but you
can't accidentally introduce a typo like "cLean" and have
it silently pass.
// This works correctly — strict symbol comparison
if (verdict === Verdict.Clean) { ... }
// This would never match (not the same Symbol reference)
// if (verdict === 'Clean') { ... } ← won't compile in TypeScript
Complete Express.js upload example
Here is a working Express endpoint that accepts a file upload, scans it, and either saves it or rejects the request. It uses Multer to handle multipart form parsing.
npm install express multer pompelmi
const express = require('express');
const multer = require('multer');
const { scan, Verdict } = require('pompelmi');
const path = require('path');
const fs = require('fs');
const os = require('os');
const app = express();
const upload = multer({ dest: os.tmpdir() });
app.post('/upload', upload.single('file'), async (req, res) => {
// multer writes the file to a temp path before this handler runs
if (!req.file) {
return res.status(400).json({ error: 'No file provided.' });
}
const tmpPath = req.file.path;
try {
const verdict = await scan(tmpPath);
if (verdict === Verdict.Malicious) {
// Delete the file — do not keep malware on disk
fs.unlinkSync(tmpPath);
return res.status(400).json({ error: 'Malware detected. File rejected.' });
}
if (verdict === Verdict.ScanError) {
fs.unlinkSync(tmpPath);
return res.status(422).json({ error: 'Scan incomplete. Rejected as a precaution.' });
}
// File is clean — move to your permanent storage
const dest = path.join('/var/app/uploads', req.file.originalname);
fs.renameSync(tmpPath, dest);
return res.json({ status: 'ok', file: req.file.originalname });
} catch (err) {
// ClamAV is not installed, or another system error
if (fs.existsSync(tmpPath)) fs.unlinkSync(tmpPath);
return res.status(500).json({ error: 'Scan failed: ' + err.message });
}
});
app.listen(3000, () => console.log('Listening on http://localhost:3000'));
Test it with curl:
# Upload a normal file — should return 200 curl -F "file=@/etc/hosts" http://localhost:3000/upload # Upload the EICAR test virus — should return 400 echo 'X5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*' \ > /tmp/eicar.txt curl -F "file=@/tmp/eicar.txt" http://localhost:3000/upload
Handling each verdict correctly
| Verdict | Meaning | Recommended action |
|---|---|---|
Verdict.Clean |
No malware found in the file. | Move to permanent storage, proceed normally. |
Verdict.Malicious |
ClamAV matched a known malware signature. | Delete the temp file immediately. Return HTTP 400. Log the event. |
Verdict.ScanError |
Scan ran but produced an inconclusive result (e.g. corrupted archive, read error). | Delete the temp file. Return HTTP 422. Reject unless you explicitly trust the source. |
ScanError through to storage. An attacker
can sometimes force a scan error intentionally to bypass scanning. Treat it
as untrusted.
Next steps
- Using a different framework? See the full Express.js integration guide for middleware patterns, TypeScript types, and multi-file scanning.
- Worried about more than just viruses? Read the Node.js file upload security checklist for a complete picture of upload risks.
- Running in Docker? See Running pompelmi with ClamAV in Docker Compose.