IAnswerable

Handle File Uploads Safely

27 Jul 2026

Every file upload endpoint is an attacker’s first stop. It’s the one place your app accepts arbitrary bytes from a stranger and does something with them — store them, serve them, maybe process them. Get the basics wrong and you’ve built a malware host, a storage bomb, or a path traversal bug with a UI.

Never trust the extension or the declared MIME type

A browser sends whatever Content-Type the client wants. A file named photo.jpg can be a PHP script, an HTML file with a script tag, or a zip bomb. Check the actual file content — its magic bytes — not what the client claims:

import { fileTypeFromBuffer } from 'file-type'

const buffer = await file.arrayBuffer()
const detected = await fileTypeFromBuffer(buffer)
if (!detected || !['image/jpeg', 'image/png', 'application/pdf'].includes(detected.mime)) {
  throw new Error('Unsupported file type')
}

This catches the common trick of renaming an executable to look like an image. It won’t catch a genuinely malicious JPEG (a crafted file that exploits a bug in your image library), which is a separate problem — see virus scanning below.

Size limits, before the file finishes uploading

Enforce limits at the proxy or framework level, not after the whole file is already in memory or on disk:

client_max_body_size 10m;
app.use(express.json({ limit: '10mb' }))

Without a hard cap, a single user (or a script hitting your endpoint in a loop) can fill your disk or your object storage bill with multi-gigabyte uploads. Set the limit per file type — 10MB might be generous for a profile photo and stingy for a video upload.

Storage layout: never trust the filename

Don’t store the file at a path built from the user-supplied filename. ../../etc/passwd.jpg as a filename is the oldest trick in the book, and building a disk path by string-concatenating it is how you get path traversal.

const key = `${tenantId}/${crypto.randomUUID()}.${detected.ext}`
await s3.putObject({ Bucket: 'uploads', Key: key, Body: buffer })

Generate the storage key yourself. Store the user’s original filename as metadata if you need to show it back to them, but never let it touch a filesystem path or an object storage key.

Upload directly to object storage

Routing every upload through your app server means your app server’s memory and bandwidth are the bottleneck for every file, even huge ones. A signed upload URL lets the client (or their browser) upload directly to S3/R2/GCS, and your server only handles the metadata:

const { url, fields } = await s3.createPresignedPost({
  Bucket: 'uploads',
  Key: key,
  Conditions: [['content-length-range', 0, 10 * 1024 * 1024]],
  Expires: 300,
})

Your app server never touches the file bytes. It just hands out a short-lived URL and gets a webhook or client callback when the upload finishes.

Virus scanning

For anything a user other than the uploader will ever open — shared documents, forum attachments, support ticket uploads — scan before you let it near another user. Self-hosted ClamAV works for moderate volume; for higher volume, a hosted scanning API is less ops overhead than running and updating virus definitions yourself. Scan asynchronously after upload, quarantine the file until the scan clears, and don’t serve it to anyone until it passes.

The checklist

Skip any one of these and the failure isn’t hypothetical — it’s the specific attack someone will try against your upload form within the first month it’s public.