01.Never Upload Through Your Server
Routing file uploads through your API server is a performance anti-pattern and a security risk. Instead, issue pre-signed S3 URLs directly to the client. The client uploads directly to S3, your server never touches the raw bytes, and you get fine-grained control over what can be uploaded.
const command = new PutObjectCommand({
Bucket: process.env.S3_BUCKET,
Key: `uploads/${userId}/${uuid()}.pdf`,
ContentType: 'application/pdf',
ContentLength: fileSize, // enforce size limit
});
const url = await getSignedUrl(s3Client, command, { expiresIn: 300 });02.MIME Validation — Don't Trust the Extension
File extensions are user-controlled and meaningless for security. Always validate the actual MIME type by inspecting the file's magic bytes (first 8 bytes). A file named 'invoice.pdf' could contain an executable. Use the 'file-type' library to detect real MIME types server-side.
03.Virus Scanning With ClamAV on Lambda
Run ClamAV on a Lambda function triggered by S3 object creation events. If the scan finds a threat, delete the object immediately and notify the uploader. If clean, move the file to a 'clean' prefix and make it accessible. Never serve files that haven't been scanned.
- ▸Trigger: S3 ObjectCreated event → Lambda
- ▸Scan with ClamAV (packaged in Lambda layer)
- ▸Tag object: scan-status=CLEAN or scan-status=INFECTED
- ▸Infected files deleted within milliseconds of upload
04.Bucket Policy — Least Privilege
Your S3 bucket should deny all public access by default. Access should only be granted via pre-signed URLs or through CloudFront with origin access identity. Never grant s3:GetObject to '*'. Separate buckets for uploads (write-only), processing (Lambda access), and clean files (CloudFront access).