Secure Node.js File Uploads with Multer, Sharp, Zod, and S3
Learn secure Node.js file uploads with Multer, Sharp, Zod, and S3 to validate, optimize, and scale uploads safely. Read the guide.
I remember the exact moment I realized vanilla file uploads were a ticking time bomb. It was a Tuesday at 2 AM. A client uploaded a 15 MB image to our production server, and the entire Node process ground to a halt. The request payload crashed Node’s memory allocator, the CPU spiked to 100%, and the user was staring at a blank screen for four minutes before getting a 503. That night I learned that handling file uploads in production isn’t about just accepting a blob and saving it. It’s about controlling every byte that enters your system, transforming it efficiently, and storing it in a way that doesn’t throttle your own infrastructure. This article is the result of that late-night lesson, combined with years of building image-heavy platforms.
Let’s start with a question that often gets overlooked: What happens when you let an attacker upload a file named “photo.jpg.exe”? If your answer relies solely on a client-side check, you’re in for a surprise. The first line of defense is Multer, but not the default one you’ve seen in tutorials. I configure Multer to use memory storage and a strict file filter that validates both the MIME type and the file extension. Why two checks? Because browsers can lie. A file with a .png extension can contain an executable, and the MIME type reported by the browser is often just “application/octet-stream”. Here’s the filter I use in production:
const ALLOWED_MIME_TYPES = [
'image/jpeg', 'image/png', 'image/webp', 'image/gif', 'application/pdf'
];
const ALLOWED_EXTENSIONS = ['jpg', 'jpeg', 'png', 'webp', 'gif', 'pdf'];
const fileFilter = (req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase().slice(1);
const mimePass = ALLOWED_MIME_TYPES.includes(file.mimetype);
const extPass = ALLOWED_EXTENSIONS.includes(ext);
cb(null, mimePass && extPass);
};
const upload = multer({ storage: multer.memoryStorage(), fileFilter, limits: { fileSize: 10 * 1024 * 1024 } });
Notice I use memoryStorage — no temporary files on disk. That’s critical for serverless environments and containerized deployments where disk space is ephemeral and expensive. But memory storage means you must process the buffer immediately, or you’ll leak memory. That’s where Sharp comes in.
Sharp is your image processing bulldozer. It can resize, compress, and convert formats in a single pipeline, all in memory and incredibly fast. I’ve seen teams upload images directly to S3 and then use Lambda to process them later, but that adds latency and cost. Instead, I process right after multer hands me the buffer. Here’s an example:
import sharp from 'sharp';
async function processImage(buffer: Buffer, options: { width?: number; quality?: number }): Promise<Buffer> {
let pipeline = sharp(buffer);
if (options.width) pipeline = pipeline.resize(options.width, null, { fit: 'inside', withoutEnlargement: true });
pipeline = pipeline.webp({ quality: options.quality ?? 80 });
return pipeline.toBuffer();
}
I always convert to WebP and lower the quality to 80 — that reduces file size by 40% on average without visible loss. Would you rather serve a 5MB JPEG or a 1.5MB WebP? Your users on slow networks will thank you.
Now, once the buffer is processed, the real fun begins: streaming it to AWS S3 with the SDK v3. Many old examples use a PutObjectCommand with the entire buffer, but that loads the entire file into Node’s memory again. Instead, use Upload from @aws-sdk/lib-storage which streams the buffer in chunks. But since we already have the buffer, it’s fine. The bigger concern is naming. I never store files with their original names — that’s a path traversal vulnerability waiting to happen. Use a UUID and the correct extension:
import { PutObjectCommand } from '@aws-sdk/client-s3';
import { v4 as uuidv4 } from 'uuid';
const key = `uploads/${uuidv4()}.webp`;
const command = new PutObjectCommand({
Bucket: process.env.AWS_S3_BUCKET_NAME,
Key: key,
Body: processedBuffer,
ContentType: 'image/webp',
CacheControl: 'public, max-age=31536000',
});
await s3Client.send(command);
I set a CacheControl header with a one-year max-age. Why? Because S3 objects are immutable (you should never overwrite an object), and browsers can cache images aggressively. Combined with a unique key, you get a perfect cache-busting strategy.
But what about access control? You don’t want your S3 bucket to be publicly readable. Instead, generate signed URLs that expire. Here’s a snippet using getSignedUrl:
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { GetObjectCommand } from '@aws-sdk/client-s3';
async function getSignedUrl(key: string, expiresIn = 3600): Promise<string> {
const command = new GetObjectCommand({ Bucket: bucketName, Key: key });
return getSignedUrl(s3Client, command, { expiresIn });
}
Now the users can access the file only through a time-limited URL. This is how services like WhatsApp share images without exposing their bucket.
But what about validation beyond file types? I use Zod to validate the entire request body, including the file metadata. Here’s a simple schema:
import { z } from 'zod';
export const uploadSchema = z.object({
folder: z.string().optional().default('uploads'),
resizeWidth: z.coerce.number().int().positive().optional(),
quality: z.coerce.number().int().min(1).max(100).optional().default(80),
});
This ensures that even if a client sends a resizeWidth of NaN, it’s caught before reaching Sharp. How many times have you seen a production error because a query parameter was undefined? Using Zod eliminates that class of bugs entirely.
Now, error handling. A file upload failure is not just a 500 error. It’s a UX nightmare. I wrap every step in try-catch and return structured errors:
try {
const processed = await processImage(req.file.buffer, opts);
const result = await uploadToS3(processed);
res.status(201).json({ success: true, url: result.signedUrl });
} catch (err) {
console.error('Upload failed', err);
res.status(422).json({ success: false, error: 'File processing failed.' });
}
Always log the actual error server-side, but return a user-friendly message.
Finally, testing. Without integration tests, your upload endpoint is a black box. I use Supertest and Vitest to simulate file uploads:
import request from 'supertest';
import app from '../app';
it('uploads an image successfully', async () => {
const buffer = Buffer.from('fake-image-data');
const res = await request(app)
.post('/upload')
.attach('file', buffer, 'test.jpg')
.field('resizeWidth', 800);
expect(res.status).toBe(201);
expect(res.body.url).toContain('https://');
});
This test confirms the whole pipeline works locally before deployment.
I wish I had understood all these pieces from the start. The file upload ecosystem is full of traps: temp files filling up the /tmp directory, MIME type spoofing, memory bloat, unoptimized images burning bandwidth, and unsigned S3 URLs leaking your storage. By combining Multer’s filter, Sharp’s pipeline, S3’s signed URLs, and Zod’s validation, you build a system that not only works but scales without surprises.
If you’ve made it this far, you’re the kind of developer who cares about the details. That’s rare. I’d love to hear your own war stories — drop a comment below. And if this saved you from a 2 AM crisis, hit that like button and share it with your team. They’ll thank you later.
As a best-selling author, I invite you to explore my books on Amazon. Don’t forget to follow me on Medium and show your support. Thank you! Your support means the world!
101 Books
101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.
Check out our book Golang Clean Code available on Amazon.
Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!
📘 Checkout my latest ebook for free on my channel!
Be sure to like, share, comment, and subscribe to the channel!
Our Creations
Be sure to check out our creations:
Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | JS Schools
We are on Medium
Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva