How to Build a Secure File Upload API with Node.js and S3
File upload endpoints are among the most abused attack surfaces in web applications. Developers wire up multer, push bytes to S3, and ship — leaving the door open to malware uploads, storage abuse, content-type spoofing, and direct object exposure. This guide closes those gaps one by one.
Why Upload Endpoints Are High-Risk
An upload endpoint accepts arbitrary bytes from the internet and writes them somewhere persistent. Without guardrails, an attacker can:
- Upload a PHP/shell script disguised as a
.jpgand trick a misconfigured server into executing it - Flood your S3 bucket with gigabyte-sized files, running up your AWS bill
- Enumerate or directly access other users' files via predictable S3 keys
- Bypass your frontend validation entirely by sending raw HTTP requests
The fixes are not complicated — they are just consistently skipped in tutorials.
1. Validate File Type on the Server (Not Just the Extension)
Never trust the file extension or the Content-Type header the client sends. Both are trivially spoofed. Instead, read the file's magic bytes — the first few bytes of the binary content that identify its true format.
The file-type npm package does this cleanly:
import { fileTypeFromBuffer } from 'file-type';
const ALLOWED_MIME_TYPES = new Set(['image/jpeg', 'image/png', 'application/pdf']);
async function validateFileType(buffer) {
const type = await fileTypeFromBuffer(buffer);
if (!type || !ALLOWED_MIME_TYPES.has(type.mime)) {
throw new Error(`File type not permitted: ${type?.mime ?? 'unknown'}`);
}
return type;
}
This runs after the file lands in memory but before it ever touches S3. If the magic bytes do not match an allowed MIME type, the upload is rejected immediately.
2. Enforce Hard Size Limits Early
Do not wait until the entire file is buffered to check its size. Configure your parser to reject oversized requests at the stream level. With multer:
const upload = multer({
storage: multer.memoryStorage(),
limits: {
fileSize: 10 * 1024 * 1024, // 10 MB hard cap
files: 1, // one file per request
},
});
Set this limit in your reverse proxy (NGINX or AWS API Gateway) as well. Defense in depth means you do not rely on a single layer.
3. Scan for Malware Before Storing
This step is almost universally skipped in tutorials, yet it is critical for any platform that stores files on behalf of users — SaaS products, HR tools, document management systems, anything.
ClamAV is a battle-tested open-source antivirus engine. You can run it as a sidecar service and scan the in-memory buffer before committing to S3:
import NodeClam from 'clamscan';
const clamscan = await new NodeClam().init({ clamdscan: { active: true } });
async function scanBuffer(buffer, filename) {
const { isInfected, viruses } = await clamscan.scanBuffer(buffer, filename);
if (isInfected) {
throw new Error(`Malware detected: ${viruses.join(', ')}`);
}
}
For cloud-native teams, AWS offers Amazon Macie and third-party solutions like Trend Micro Cloud One that integrate directly with S3 event notifications — scanning files post-upload and quarantining flagged objects automatically.
4. Use Pre-Signed URLs for Direct-to-S3 Uploads
Routing every upload through your application server is a scalability bottleneck. The better pattern is to issue a pre-signed URL from your API, then have the client upload directly to S3. Your server never touches the bytes.
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { randomUUID } from 'crypto';
const s3 = new S3Client({ region: process.env.AWS_REGION });
async function generateUploadUrl(mimeType, userId) {
const key = `uploads/${userId}/${randomUUID()}`;
const command = new PutObjectCommand({
Bucket: process.env.S3_BUCKET,
Key: key,
ContentType: mimeType,
// Enforce content length on the S3 side using a bucket policy
});
const url = await getSignedUrl(s3, command, { expiresIn: 300 }); // 5 minutes
return { url, key };
}
Key security points for pre-signed URLs:
- Short expiry — 5 minutes is usually enough. Long-lived signed URLs leak.
- Unpredictable keys — use UUIDs, not usernames or timestamps. Predictable keys allow enumeration.
- Scope by user — prefix every key with the authenticated user's ID so IAM policies and application logic can enforce ownership.
- Block public access — your S3 bucket should have all public access blocked. Serve files through pre-signed GET URLs or a CloudFront distribution, not raw S3 URLs.
5. Validate After Upload with S3 Event Triggers
Even with pre-signed uploads, a motivated attacker can manipulate the request after the URL is issued. Add a second validation layer using an S3 event notification that triggers a Lambda function on ObjectCreated. The Lambda re-validates the file type, checks size, runs the virus scan, and either moves the file to a "clean" bucket or deletes it.
This quarantine pattern is standard in compliance-heavy industries (fintech, health tech) and worth adopting early — retrofitting it later is painful.
6. Lock Down Your S3 Bucket Policy
Your bucket should never allow s3:GetObject or s3:PutObject for "Principal": "*". A minimal upload policy grants PutObject only to your application's IAM role, scoped to the uploads prefix:
{
"Effect": "Allow",
"Principal": { "AWS": "arn:aws:iam::ACCOUNT_ID:role/upload-service-role" },
"Action": "s3:PutObject",
"Resource": "arn:aws:s3:::your-bucket/uploads/*"
}
Pair this with S3 Object Ownership set to BucketOwnerEnforced so that no uploaded object can override ACLs.
Putting It All Together
A production-grade upload flow looks like this:
- Client authenticates and requests an upload URL from your API
- API validates the requested MIME type, issues a short-lived pre-signed URL with a UUID key scoped to the user
- Client uploads directly to S3
- S3 event triggers a Lambda that scans, validates, and moves the file to a clean prefix
- API stores the clean object key in your database and issues a pre-signed GET URL on demand
Each layer can fail independently without breaking the others — and each layer stops a different class of attack.
Why This Matters for Your Project
Whether you are building a SaaS platform, a mobile app backend, or an internal tool, file uploads are often an afterthought until something goes wrong. Implementing these controls from day one costs a few hours; recovering from a malware incident, a storage bill shock, or a compliance audit costs weeks. Secure upload infrastructure is not a premium feature — it is table stakes for any production system handling user-generated content.





