Handling file uploads sounds simple until you actually ship it to production. Suddenly you’re worried about oversized files crashing your server, malicious MIME types slipping through, and how to serve private files without exposing your S3 bucket to the world.
In this tutorial, we’ll build a production-ready file upload endpoint in Node.js using Express, Multer for multipart parsing, and AWS S3 for durable storage. We’ll also cover signed URLs, validation, and size limits so your endpoint is safe to deploy. See fullstackfoundations.com for their take.
Why This Stack for Node.js File Upload to S3?
Before jumping into code, here’s why this combination works so well:
- Express: minimal, flexible, and battle-tested for building HTTP endpoints.
- Multer: the standard middleware for parsing
multipart/form-datain Node.js. - AWS S3: virtually unlimited storage, 99.999999999% durability, and pay-as-you-go pricing.
- Signed URLs: keep your bucket private while granting temporary, secure access to specific objects.

Two Upload Strategies: Which One Should You Pick?
There are two common patterns for uploading files to S3 from a Node.js app. Understanding the trade-offs will save you a lot of refactoring later.
| Strategy | How it works | Best for |
|---|---|---|
| Server-side upload | Client sends file to your server, server forwards it to S3. | Small files, when you need to validate or transform content before storing. |
| Direct upload with presigned URL | Server generates a signed URL, client uploads directly to S3. | Large files, high-traffic apps, offloading bandwidth from your server. |
We’ll cover both approaches in this guide.
Prerequisites
- Node.js 20+ installed
- An AWS account with an S3 bucket created
- An IAM user with programmatic access and permissions for
s3:PutObject,s3:GetObject - Basic familiarity with Express
Step 1: Project Setup
Create a new project and install the required dependencies:
mkdir node-s3-upload && cd node-s3-upload
npm init -y
npm install express multer @aws-sdk/client-s3 @aws-sdk/s3-request-presigner dotenv
Create a .env file at the root:
AWS_REGION=eu-west-3
AWS_ACCESS_KEY_ID=your_access_key
AWS_SECRET_ACCESS_KEY=your_secret_key
S3_BUCKET=your-bucket-name
Never commit this file. Add .env to your .gitignore immediately. This discussion raises a few points we skipped.

Step 2: Configure the S3 Client
Create a file s3Client.js:
import { S3Client } from "@aws-sdk/client-s3";
import "dotenv/config";
export const s3 = new S3Client({
region: process.env.AWS_REGION,
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
},
});
export const BUCKET = process.env.S3_BUCKET;
Step 3: Configure Multer with Validation and Size Limits
We’ll use Multer’s memory storage so we can stream the buffer directly to S3 without touching the disk. This is the key file, so pay attention to the validation and size limits:
import multer from "multer";
const ALLOWED_MIME = [
"image/jpeg",
"image/png",
"image/webp",
"application/pdf",
];
export const upload = multer({
storage: multer.memoryStorage(),
limits: {
fileSize: 10 * 1024 * 1024, // 10 MB
files: 5,
},
fileFilter: (req, file, cb) => {
if (!ALLOWED_MIME.includes(file.mimetype)) {
return cb(new Error("Unsupported file type"), false);
}
cb(null, true);
},
});
A few production tips baked into this config:
- Whitelist MIME types, don’t blacklist. Attackers are creative.
- Cap file size to prevent memory exhaustion attacks.
- Limit number of files per request.
- Consider also validating the file signature (magic bytes) since the MIME header can be spoofed. Libraries like
file-typehelp here.
Step 4: Build the Upload Endpoint (Server-Side Upload)
Now the Express server that takes the file and pushes it to S3:
import express from "express";
import crypto from "crypto";
import { PutObjectCommand } from "@aws-sdk/client-s3";
import { s3, BUCKET } from "./s3Client.js";
import { upload } from "./uploadMiddleware.js";
const app = express();
app.post("/upload", upload.single("file"), async (req, res) => {
try {
if (!req.file) {
return res.status(400).json({ error: "No file provided" });
}
const ext = req.file.originalname.split(".").pop();
const key = `uploads/${crypto.randomUUID()}.${ext}`;
await s3.send(new PutObjectCommand({
Bucket: BUCKET,
Key: key,
Body: req.file.buffer,
ContentType: req.file.mimetype,
}));
res.json({ key, message: "Upload successful" });
} catch (err) {
console.error(err);
res.status(500).json({ error: err.message });
}
});
app.listen(3000, () => console.log("Listening on :3000"));
Notice we never trust the original filename. Using a UUID as the object key prevents path traversal, filename collisions, and enumeration attacks.
Step 5: Generate Signed URLs to Serve Private Files
Your bucket should be private by default. To let a user download a file, generate a temporary signed URL:
import { GetObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
app.get("/files/:key", async (req, res) => {
const command = new GetObjectCommand({
Bucket: BUCKET,
Key: `uploads/${req.params.key}`,
});
const url = await getSignedUrl(s3, command, { expiresIn: 300 }); // 5 min
res.json({ url });
});
The URL expires after 300 seconds, so even if it leaks, the exposure window is small.

Step 6 (Alternative): Direct Upload with Presigned PUT URLs
For large files or high traffic, skip your server entirely. Generate a presigned upload URL and let the client PUT directly to S3:
app.post("/presign", express.json(), async (req, res) => {
const { filename, contentType } = req.body;
// Validate on the server before signing
if (!ALLOWED_MIME.includes(contentType)) {
return res.status(400).json({ error: "Invalid content type" });
}
const key = `uploads/${crypto.randomUUID()}-${filename}`;
const command = new PutObjectCommand({
Bucket: BUCKET,
Key: key,
ContentType: contentType,
});
const url = await getSignedUrl(s3, command, { expiresIn: 60 });
res.json({ url, key });
});
Your frontend then does something like:
await fetch(url, {
method: "PUT",
headers: { "Content-Type": file.type },
body: file,
});
The advantages are huge: your server never sees the file bytes, so upload bandwidth and memory usage stay flat regardless of file size.
Production Checklist
Before deploying your Node.js file upload to S3 pipeline, run through this list:
- Block bucket public access at the S3 level
- Enable S3 default encryption (SSE-S3 or SSE-KMS)
- Enable versioning on the bucket to recover from accidental overwrites
- Use IAM roles instead of static keys when running on EC2, ECS, or Lambda
- Add rate limiting (e.g.,
express-rate-limit) on upload endpoints - Scan uploaded files for malware if user-generated content is public
- Log every upload with user ID, file key, and size for auditing
- Set a lifecycle rule to move old files to Glacier or delete them
Common Pitfalls to Avoid
- Using disk storage on serverless: Lambda’s ephemeral
/tmpis limited. Prefer memory storage or presigned URLs. - Trusting the client MIME type: always validate on the server, and verify with magic bytes for sensitive apps.
- Making the bucket public: use signed URLs instead. Public buckets are one of the top causes of data leaks.
- Long signed URL expiration: keep it short. 5 to 15 minutes is usually enough.
- No CORS configuration: if you’re using direct uploads from the browser, configure CORS on the bucket to allow your origin.
FAQ
Should I use Multer or multer-s3?
multer-s3 streams directly from Multer to S3, which is convenient. However, plain Multer with the AWS SDK v3 gives you more control over validation, error handling, and retries. For most production apps, we recommend plain Multer plus the SDK.
What’s the maximum file size I can upload to S3?
A single PUT is capped at 5 GB. For anything larger, use S3 multipart upload, which the AWS SDK exposes through Upload from @aws-sdk/lib-storage. freecodecamp.org has covered this at length.
How do I handle uploads on AWS Lambda?
API Gateway has a 10 MB payload limit and Lambda has a 6 MB synchronous invocation limit. For anything above that, use presigned URLs so the client uploads directly to S3.
Can I validate file contents beyond the MIME type?
Yes, and you should. Use the file-type package to read magic bytes from the buffer and confirm the file actually matches the declared type. This blocks polyglot files and spoofed extensions.
Do I need to delete files from S3 when a user deletes their record?
S3 does not automatically delete anything. Either call DeleteObjectCommand when the record is removed, or set a lifecycle policy for orphaned prefixes.
Wrapping Up
You now have a full blueprint for handling Node.js file upload to S3 the right way: Multer for parsing, strict validation, size limits, private buckets, and signed URLs for both reading and writing. Whether you route uploads through your Express server or generate presigned URLs for direct-to-S3 uploads, you’re set up for a secure and scalable pipeline.
Need help architecting file storage or scaling your Node.js backend? Get in touch with our team and we’ll be happy to review your setup.
