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).

New to this topic? Start with Getting started with antivirus scanning in Node.js to set up ClamAV in under 5 minutes, then come back here for the full picture.

How it works

The flow is straightforward:

  1. The user submits a file through your upload form or API.
  2. Your server receives the file and writes it to a temporary location on disk.
  3. You call scan(filePath) — pompelmi passes the file to ClamAV.
  4. ClamAV checks the file against its virus database and returns a result.
  5. pompelmi returns a Verdict: Verdict.Clean, Verdict.Malicious, or Verdict.ScanError.
  6. 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:

macOS
brew install clamav && freshclam
Linux (Debian / Ubuntu)
sudo apt-get install -y clamav && sudo freshclam
Windows
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
The EICAR string is a harmless test file that every antivirus engine recognises as "malware" for testing purposes. It contains no actual malicious code. Use it to verify your scan pipeline is working.

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.
Never silently pass a ScanError through to storage. An attacker can sometimes force a scan error intentionally to bypass scanning. Treat it as untrusted.

Next steps