Node.js file upload security checklist (with virus scanning)

File uploads are a high-risk surface. This checklist covers every control you should have in place before accepting uploads from untrusted users. Each item links to code you can copy directly.

For a deeper look at the "why" behind each risk, see How to protect your Node.js app from malicious file uploads.
# Control Prevents
1 Enforce file size limits before writing to disk DoS, disk exhaustion, zip bombs
2 Validate content type via magic bytes File type spoofing, disguised executables
3 Never use the original filename on disk Path traversal, overwrite attacks
4 Scan with ClamAV via pompelmi Viruses, trojans, ransomware, malicious macros
5 Store uploads in an isolated location Code execution, accidental exposure
6 Serve with correct headers XSS via SVG/HTML uploads, MIME sniffing
7 Log rejected uploads Blind spots in incident response

1. Enforce file size limits before writing to disk

Configure your multipart parser to enforce a hard size limit. With Multer, fileSize is enforced while streaming — the upload is rejected before it's fully written.

const multer = require('multer');
const os     = require('os');

const upload = multer({
  dest:   os.tmpdir(),
  limits: {
    fileSize:  10 * 1024 * 1024,   // 10 MB
    files:     5                    // max 5 files per request
  }
});

// Catch Multer errors in an error middleware
app.use((err, req, res, next) => {
  if (err.code === 'LIMIT_FILE_SIZE') {
    return res.status(413).json({ error: 'File too large. Limit is 10 MB.' });
  }
  if (err.code === 'LIMIT_FILE_COUNT') {
    return res.status(400).json({ error: 'Too many files.' });
  }
  next(err);
});

2. Validate content type via magic bytes

Check the actual bytes at the start of the file, not just the Content-Type header or file extension — both are user-controlled.

const { fileTypeFromFile } = require('file-type');  // npm install file-type

const ALLOWED_MIME = new Set([
  'image/jpeg', 'image/png', 'image/gif', 'image/webp',
  'application/pdf',
  'application/vnd.openxmlformats-officedocument.wordprocessingml.document',  // .docx
  'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'         // .xlsx
]);

async function validateMimeType(filePath) {
  const type = await fileTypeFromFile(filePath);
  if (!type || !ALLOWED_MIME.has(type.mime)) {
    throw Object.assign(
      new Error('File type not permitted: ' + (type ? type.mime : 'unknown')),
      { statusCode: 415 }
    );
  }
  return type;
}

3. Never use the original filename on disk

The original filename is user input. Treat it as untrusted and never use it as a filesystem path. Generate a random name for storage; keep the original in your database if you need to display it.

const crypto = require('crypto');
const path   = require('path');

function generateSafeFilename(mimeType, ext) {
  // ext comes from magic byte detection, not user input
  return crypto.randomBytes(20).toString('hex') + (ext ? '.' + ext : '');
}

// Usage:
const type     = await validateMimeType(tmpPath);   // from step 2
const safeName = generateSafeFilename(type.mime, type.ext);
// e.g. "a3f5c8d2...b7.pdf"

4. Scan with ClamAV via pompelmi

const { scan, Verdict } = require('pompelmi');
const fs = require('fs');

async function antivirusScan(tmpPath) {
  const verdict = await scan(tmpPath);

  if (verdict === Verdict.Malicious) {
    fs.unlinkSync(tmpPath);
    throw Object.assign(new Error('Malware detected. Upload rejected.'), { statusCode: 400 });
  }

  if (verdict === Verdict.ScanError) {
    fs.unlinkSync(tmpPath);
    throw Object.assign(
      new Error('Scan incomplete. File rejected as a precaution.'),
      { statusCode: 422 }
    );
  }
  // Verdict.Clean — continue
}
Never silently skip a ScanError. An attacker may intentionally cause scan errors (e.g. by submitting a deeply nested archive) to bypass scanning. Treat any non-Clean verdict as untrusted.

Not set up yet? See Getting started with antivirus scanning in Node.js.

5. Store uploads in an isolated location

Don't store uploads inside your web root, your application directory, or any directory your web server might serve or execute. Keep them in a dedicated directory with restricted permissions.

// Bad — accessible via your web server's static file serving
const dest = path.join(__dirname, 'public', 'uploads', safeName);

// Good — outside the web root, only readable by the app user
const dest = path.join('/var/app/uploads', safeName);

fs.renameSync(tmpPath, dest);

On Linux, ensure the upload directory is owned by the application user and not writable by the web server process:

sudo mkdir -p /var/app/uploads
sudo chown appuser:appuser /var/app/uploads
sudo chmod 750 /var/app/uploads

6. Serve uploads with correct HTTP headers

An SVG file or HTML file that passes virus scanning can still execute JavaScript in the browser if served inline from your main origin. Force download mode and prevent MIME sniffing.

app.get('/files/:id', async (req, res) => {
  const record = await db.files.findById(req.params.id);
  if (!record) return res.status(404).end();

  const filePath = path.join('/var/app/uploads', record.storedName);

  // Force download — prevents browser from rendering HTML/SVG inline
  res.setHeader('Content-Disposition',
    `attachment; filename="${record.originalName}"`);

  // Prevent MIME sniffing
  res.setHeader('X-Content-Type-Options', 'nosniff');

  // Set the correct type from the database (magic-byte verified at upload time)
  res.setHeader('Content-Type', record.mimeType);

  res.sendFile(filePath);
});
For user-facing file previews (images, PDFs), serve from a separate subdomain or from object storage (S3, GCS). This isolates any malicious content from your main application's cookies and origin.

7. Log rejected uploads

Log every rejected upload with enough context to investigate later. At minimum: IP address, timestamp, original filename, MIME type, and reason for rejection.

function logRejection(req, file, reason) {
  console.warn(JSON.stringify({
    event:        'upload_rejected',
    ip:           req.ip,
    originalName: file ? file.originalname : null,
    mimeType:     file ? file.mimetype     : null,
    size:         file ? file.size         : null,
    reason,
  }));
}

Putting it all together — complete upload handler

const express = require('express');
const multer  = require('multer');
const { scan, Verdict } = require('pompelmi');
const { fileTypeFromFile } = require('file-type');
const crypto = require('crypto');
const path   = require('path');
const fs     = require('fs');
const os     = require('os');

const app    = express();
const upload = multer({
  dest:   os.tmpdir(),
  limits: { fileSize: 10 * 1024 * 1024, files: 1 }
});

const ALLOWED_MIME = new Set(['image/jpeg','image/png','application/pdf']);

function logRejection(req, file, reason) {
  console.warn(JSON.stringify({ event: 'upload_rejected', ip: req.ip,
    name: file?.originalname, mime: file?.mimetype, reason }));
}

app.post('/upload', upload.single('file'), async (req, res) => {
  if (!req.file) {
    return res.status(400).json({ error: 'No file provided.' });
  }

  const tmpPath = req.file.path;
  let promoted  = false;

  try {
    // 1. MIME validation
    const type = await fileTypeFromFile(tmpPath);
    if (!type || !ALLOWED_MIME.has(type.mime)) {
      logRejection(req, req.file, 'invalid_mime:' + (type?.mime || 'unknown'));
      return res.status(415).json({ error: 'File type not permitted.' });
    }

    // 2. Virus scan
    const verdict = await scan(tmpPath);
    if (verdict !== Verdict.Clean) {
      logRejection(req, req.file, verdict.description);
      return res.status(verdict === Verdict.Malicious ? 400 : 422).json({
        error: verdict === Verdict.Malicious
          ? 'Malware detected.'
          : 'Scan incomplete. Rejected as precaution.'
      });
    }

    // 3. Safe filename + isolated storage
    const safeName = crypto.randomBytes(20).toString('hex') + '.' + type.ext;
    const dest     = path.join('/var/app/uploads', safeName);
    fs.renameSync(tmpPath, dest);
    promoted = true;

    // 4. Persist record to DB (pseudo-code)
    // await db.files.create({ id: safeName, originalName: req.file.originalname, mimeType: type.mime });

    res.json({ status: 'ok', id: safeName });

  } catch (err) {
    logRejection(req, req.file, 'error:' + err.message);
    res.status(err.statusCode || 500).json({ error: err.message });
  } finally {
    if (!promoted && fs.existsSync(tmpPath)) {
      fs.unlinkSync(tmpPath);
    }
  }
});

app.use((err, req, res, next) => {
  if (err.code === 'LIMIT_FILE_SIZE') {
    return res.status(413).json({ error: 'File too large.' });
  }
  next(err);
});