How to Scan User Uploads for Malware
If your application accepts file uploads from users, those files are an attack vector. It doesn't matter whether you're building an HR platform that takes resumes, a helpdesk that handles email attachments, or a SaaS product where customers upload documents — any file that comes from outside your trust boundary should be scanned before it goes anywhere near your infrastructure or other users.
This isn't hypothetical. Malicious files uploaded through legitimate applications are a common finding in penetration tests and a real-world attack vector. The OWASP Testing Guide lists "Test Upload of Malicious Files" as a standard check, and failing that test is one of the most common reasons companies come to us.
Why Validation Alone Isn't Enough
Most developers understand the need to validate file types and sizes. But
validation alone doesn't catch malware. A .pdf that passes your MIME type
check can still contain an exploit. A .docx can carry a macro payload. An
image file can be crafted to trigger vulnerabilities in processing libraries.
Client-side validation is easily bypassed. Server-side type checking is necessary but insufficient. You need actual malware scanning — running the file against detection engines that know what to look for.
The alternative is accepting the risk that a user uploads something malicious, it gets stored in your system, and then another user or system downloads it. At that point, your application has become the distribution mechanism.
Your First Scan
Adding malware scanning to your app starts with a single API call. You can scan a file by uploading it directly or by passing a URL where the file can be fetched.
Scan a URL
curl -H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/uploads/document.pdf"}' \
-XPOST https://YOUR_API_URL/v1.0/scans
Scan a File Upload
curl -H "Authorization: Bearer YOUR_API_TOKEN" \
-F "file=@/path/to/document.pdf" \
-XPOST https://YOUR_API_URL/v1.0/scans
The Response
Both methods return the same format:
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"status": "ok",
"filename": "document.pdf",
"content_length": 245891,
"md5": "d41d8cd98f00b204e9800998ecf8427e",
"matches": [],
"created_at": "2026-03-06T10:30:00.000Z",
"updated_at": "2026-03-06T10:30:01.234Z"
}
| Status | Meaning | Typical action |
|---|---|---|
ok |
File is clean | Accept the upload |
found |
Malware detected | Block, quarantine, or delete |
warning |
Macros, encrypted archives, etc. | Depends on your policy |
pending |
Scan still running | Poll or wait for callback |
failed |
Scan failed | Retry or flag for manual review |
That's it — you're scanning files. The simplest integration looks like this:
const scan = await scanFile(uploadedFile);
if (scan.status === "found") {
return res.status(422).json({
error: "This file was flagged as malicious.",
});
}
await saveAttachment(uploadedFile);
return res.status(200).json({ success: true });
This works, and for a simple contact form with one attachment it might be all you need. But for production applications, there's a better way.
Going Async: The Recommended Approach
The example above is synchronous — your server blocks while the scan runs,
and the user waits. For small files that's a couple of seconds, but for larger
files it can take longer. Any scan over 30 seconds automatically returns a
pending status, which means your sync code needs to handle that case anyway.
For most applications, we recommend async scanning. Your upload handler returns immediately, your app isn't tied up waiting, and you're already set up to handle files of any size or traffic spikes.
The pattern is:
- Accept the file into a staging area (a
staging/directory on disk, a separate storage bucket, a database record marked aspending) - Kick off an async scan with a callback URL
- Return immediately — the user sees "upload received"
- When the scan completes, AttachmentScanner POSTs the result to your callback
- Your callback handler moves the file to its final destination or quarantines it
Here's what that looks like:
// Upload handler — returns immediately
async function handleUpload(req, res) {
const stagedPath = await storeInStaging(req.file);
await fetch(`https://${API_URL}/v1.0/scans`, {
method: "POST",
headers: {
"Authorization": `Bearer ${API_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
url: presignedUrlFor(stagedPath),
callback: "https://your-app.com/webhooks/scan-complete",
async: true,
}),
});
return res.status(202).json({ status: "processing" });
}
// Callback handler — called by AttachmentScanner when the scan finishes
async function handleScanCallback(req, res) {
const result = req.body;
if (result.status === "ok") {
await moveToUploads(result.url);
} else if (result.status === "found") {
await moveToQuarantine(result.url, result.matches);
await notifyAdmin(result);
} else if (result.status === "warning") {
// Macros, encrypted zips — decide based on your policy
await flagForReview(result.url, result.matches);
}
return res.status(200).end();
}
This gives you everything the sync approach doesn't:
- Your upload handler returns instantly regardless of file size
- Files are staged until confirmed clean — never served to users unscanned
- You can review and investigate flagged files
- The
warningstatus (macros, encrypted archives) can go to human review - You keep a full audit trail
Note: Callbacks only fire for final statuses — not for
pending. You may receive multiple callbacks for the same scan, so use theupdated_atfield to handle duplicates.
The warning Status
AttachmentScanner flags files containing macros or encrypted zip archives as
warning rather than found, because they might be legitimate depending on
your use case. An HR platform accepting .docx resumes might want to block
macros entirely. A financial platform expecting .xlsm spreadsheets might
need to allow them. Your application needs a policy for this — and async
scanning with a staging area makes it easy to route these to a review queue.
What Languages Work?
The API is language-agnostic — anything that can make an HTTP request can scan files. We see Ruby, Python, Go, .NET, Java, Node.js, and PHP in production.
Our documentation page has links to the full API reference and a Postman collection you can use to test interactively before writing code.
Testing Your Integration
Once scanning is wired up, you need to verify it catches something. Don't upload real malware — use the EICAR test file instead.
EICAR is a standardised test file that every antivirus engine detects. It's
completely harmless but triggers a found result, confirming your integration
works end to end.
We host a copy at https://www.attachmentscanner.com/eicar.com:
curl -H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"url": "https://www.attachmentscanner.com/eicar.com"}' \
-XPOST https://YOUR_API_URL/v1.0/scans
You should get "status": "found" with "Eicar-Test-Signature" in the
matches. If you get "status": "ok", something isn't connected properly.
For more on EICAR, see What is the EICAR Test File?.
Common Mistakes
Serving files before the scan completes. If another user can download a
file while it's still in pending status, you've defeated the purpose. Always
gate access on scan status.
Only scanning on upload, not on update. If users can replace files, the replacement needs scanning too.
Trusting file extensions. A file named photo.jpg might be a renamed
executable. Scanning catches what extension checks miss.
Ignoring the warning status. Macros and encrypted archives aren't
automatically malicious, but they're not automatically safe either. Have a
policy for these.
Skipping scanning for "trusted" users. Internal users get compromised too. Scan everything.
Beyond Scanning
File scanning is one layer of a broader upload security strategy. Other things worth considering:
- File size limits — prevent denial-of-service via oversized uploads
- File type restrictions — only accept the types your application actually needs
- Content-Disposition headers — serve user-uploaded files as downloads, not inline, to reduce browser-based exploits
- Separate storage — keep user uploads isolated from your application code
- Access controls — not every user should be able to access every uploaded file
Scanning doesn't replace these measures, and they don't replace scanning. Defence in depth means using all of them together.
Getting Started
- Sign up for an account — you'll get an API token and endpoint URL
- Test with EICAR to confirm scanning works
- Set up async scanning with callbacks for production
- Integrate into your upload flow
Most developers have scanning working in under an hour. If you need help, get in touch — we're always happy to help.
AttachmentScanner Team
Other Articles
Pass a Pen Test File Upload Check
AttachmentScanner Team
A Fresh New Look
AttachmentScanner Team
AWS S3 Antivirus Protection
AttachmentScanner Team