Node.js Presigned URLs for S3 Uploads: Secure, Scalable File Uploads
Learn how to use Node.js presigned URLs for direct S3 uploads with validation, security, and better performance. Build a scalable flow now.
I used to think file uploads were straightforward. Parse a multipart form, pipe it to storage, return a URL. That approach served me for years. Then came the day my production server started choking under a 50MB video upload from a user in Brazil. The single Node.js process was saturated, request queue grew, and other users experienced timeouts. That was the moment I realized that routing every byte of an upload through my server was a design failure. The file had to travel from the client to my server, then from my server to S3, doubling latency and consuming bandwidth. I needed a better way. What if the client could send directly to S3, while I still controlled the authorization? That is the essence of presigned URLs.
Presigned URLs give you the best of both worlds. Your server remains lean – it only issues temporary permission tokens. The heavy lifting of transferring binary data happens directly between the user’s browser and AWS S3. Your server never touches the file bytes. This pattern is used by companies like Netflix and Slack for a reason: it scales horizontally without requiring your backend to be a data pipeline.
But how do you implement this safely in Node.js? Let me walk you through a production-grade setup that ensures type safety, validation, and security. I’ll share code snippets that I’ve battle-tested over dozens of projects.
First, the core infrastructure. You need an S3 bucket and an IAM user with permissions to generate presigned URLs. In AWS, create a bucket with a name like my-app-uploads-2025. Then set a bucket policy that allows only your IAM user to upload – you don’t want any anonymous writes. The IAM policy should include s3:PutObject on the bucket. Here’s a minimal policy document:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "s3:PutObject",
"Resource": "arn:aws:s3:::my-app-uploads-2025/*"
}
]
}
Now let’s configure the Node.js side. I use the AWS SDK v3 because it’s modular – you only import what you need. Create an S3 client:
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
const s3 = new S3Client({
region: "us-east-1",
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
},
});
The magic happens in the function that generates a presigned URL. You need the file’s key (the path where it will be stored in S3), the MIME type, and an expiration time. The shorter the expiration, the safer. I usually set it to 300 seconds – enough for a user with a decent connection to upload a 100MB file.
async function createPresignedUploadUrl({
key,
contentType,
expiresIn = 300,
}: {
key: string;
contentType: string;
expiresIn?: number;
}): Promise<string> {
const command = new PutObjectCommand({
Bucket: "my-app-uploads-2025",
Key: key,
ContentType: contentType,
});
const url = await getSignedUrl(s3, command, { expiresIn });
return url;
}
But wait – you should never generate a presigned URL without first validating the request. What if a malicious user sends a request to upload a 10GB video? Or tries to upload an executable file? You must enforce size limits and allowed MIME types on your server before even calling getSignedUrl. This is where type safety with Zod comes in.
I define a schema for the incoming request. The client sends the file name, MIME type, and file size in bytes. My server validates these against business rules. Here’s a typical validation:
import { z } from "zod";
const uploadRequestSchema = z.object({
fileName: z.string().min(1).max(255)
.regex(/^[a-zA-Z0-9._-]+$/, "Invalid file name"),
mimeType: z.enum([
"image/jpeg",
"image/png",
"image/webp",
"application/pdf",
"video/mp4",
]),
fileSizeBytes: z.number()
.positive()
.max(50 * 1024 * 1024, "File exceeds 50MB limit"),
});
type UploadRequest = z.infer<typeof uploadRequestSchema>;
If the request passes validation, I generate the presigned URL and also a unique upload ID (UUID) that I’ll store in the database to track the upload. The server returns both the URL and the upload ID to the client. The client then uses that URL to PUT the file directly to S3. No proxy, no server load.
The client-side code is straightforward – a simple fetch with the file blob:
const form = document.getElementById('upload-form');
form.addEventListener('submit', async (e) => {
e.preventDefault();
const file = document.getElementById('file-input').files[0];
// Step 1: Request presigned URL from backend
const response = await fetch('/api/upload/request', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
fileName: file.name,
mimeType: file.type,
fileSizeBytes: file.size,
}),
});
const { url, uploadId } = await response.json();
// Step 2: Upload file directly to S3
const uploadResponse = await fetch(url, {
method: 'PUT',
headers: { 'Content-Type': file.type },
body: file,
});
if (uploadResponse.ok) {
// Step 3: Notify backend that upload is complete
await fetch('/api/upload/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ uploadId }),
});
}
});
Notice that the server never touched the file. The entire transfer happens between the browser and S3. This offloads bandwidth and CPU from your Node.js instance, allowing it to handle more concurrent requests.
Now, how do you handle large files – say 500MB? The presigned URL approach works for files up to 5GB, but for reliability you may want to use multipart upload. The AWS SDK provides @aws-sdk/lib-storage which manages multipart uploads automatically. You can generate a presigned URL for each part if you need true client-side multipart. However, for most cases a single PUT with a presigned URL is sufficient. If you need to support resumable uploads, consider using S3 Transfer Acceleration or the AWS SDK’s Upload class.
One critical piece missing so far: security of the presigned URL itself. Anyone with the presigned URL can upload a file to that specific key location. That’s why you must keep the expiration short and limit the URL to exactly the intended file. Also, consider adding a condition in the presigned URL policy that forces the Content-Type to match what you validated. The SDK v3 allows you to add conditions using PutObjectCommand and a signing policy, but the simplest guard is to set ContentType in the command as I did above. The client must send that exact Content-Type or the upload fails.
Another layer: bucket policies. Never make your bucket public. Deny all public access. Use a bucket policy that allows only your IAM user to put objects, and maybe allow CloudFront if you serve the files later. Here’s a bucket policy that enforces server-side encryption and blocks public reads:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Deny",
"Principal": "*",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::my-app-uploads-2025/*"
},
{
"Effect": "Allow",
"Principal": { "AWS": "arn:aws:iam::123456789012:role/UploadService" },
"Action": "s3:PutObject",
"Resource": "arn:aws:s3:::my-app-uploads-2025/*",
"Condition": {
"StringEquals": {
"s3:x-amz-server-side-encryption": "AES256"
}
}
}
]
}
After the upload completes, you often need to process the file: generate thumbnails, run virus scans, or extract metadata. The classic approach is to poll S3 – but that’s inefficient. Instead, use S3 Event Notifications. When a new object is created in the bucket, S3 can push a message to an SQS queue or trigger a Lambda function. That Lambda can then process the file asynchronously. This decouples your upload flow from post-processing, making your API respond quickly.
I implement a simple notification: after the client calls /api/upload/complete, I store the upload ID and the S3 key in the database. Then a Lambda function (triggered by S3’s s3:ObjectCreated:* event) reads the object, runs a virus scan using ClamAV (mounted as a Lambda layer), and updates the database record. The frontend can then poll the status or subscribe via WebSocket. This pattern is reliable and cost-effective.
What if your application needs to support private file sharing later? You can generate presigned GET URLs for downloads, with the same security model. The same Node.js function can generate a read-only URL with a short expiry. Just use GetObjectCommand instead.
Throughout my career, I’ve seen too many developers skip validation on the server. They trust the client to send the right file size, MIME type, or even the file itself. That’s a recipe for disaster. Think about it: a user can manipulate the request to upload a script disguised as an image. Your validation on the presigned URL request is the only gatekeeper before that file lands in your bucket. If you validate only after the upload, you’ve already paid the storage cost and exposed yourself to potential abuse. Always validate before generating the URL.
Let me share a personal war story. In one project, I forgot to set a maximum file size on the presigned URL request. A user uploaded a 4K video that was 1.2GB. The upload succeeded, but my bucket policy had a 10GB limit, so no immediate disaster. However, the Lambda processing that video kept timing out at 15 seconds, and the cost increased. I ended up implementing a separate process to delete oversized files. That was a mess. To prevent this, always deflate expectations: set a generous but firm limit in the validation schema, and also enforce it in the S3 bucket policy using content-length-range. That gives you defense in depth. In your bucket policy, add:
"Condition": {
"NumericLessThanEquals": {
"s3:content-length": 52428800
}
}
Now the presigned URL will refuse any request with a Content-Length above 50MB, even if the client tries to lie.
You might be wondering about the user experience. What if the upload fails halfway? Presigned URLs are idempotent – you can retry the same URL (within the expiry window). But the client has to re-upload the entire file. For very large files, consider multipart upload with parallel parts and resume capability. That’s more complex but feasible using the AWS SDK’s Upload class on the client. However, for the majority of applications (files under 100MB), a simple PUT works reliably.
Now, let’s wrap up with a complete Express endpoint that ties everything together.
import express from "express";
import { z } from "zod";
import { v4 as uuidv4 } from "uuid";
import { createPresignedUploadUrl } from "./s3";
const app = express();
app.use(express.json());
app.post("/api/upload/request", async (req, res) => {
try {
const data = uploadRequestSchema.parse(req.body);
const key = `uploads/${uuidv4()}/${data.fileName}`; // unique path per upload
const url = await createPresignedUploadUrl({
key,
contentType: data.mimeType,
});
// Store upload metadata in DB (pseudo-code)
await db.uploads.create({ id: uuidv4(), key, status: "pending" });
res.json({ url, uploadId });
} catch (err) {
if (err instanceof z.ZodError) {
return res.status(400).json({ error: err.errors[0].message });
}
console.error(err);
res.status(500).json({ error: "Could not generate upload URL" });
}
});
app.listen(3000, () => console.log("Upload service running on port 3000"));
This endpoint is the brain of your upload system. Every time a client wants to upload a file, it first asks for permission. The server validates the request, checks the user’s quota, fraud flags, or any custom logic, and only then returns a time-limited door key. The client then walks through that door directly to S3.
I encourage you to adopt this pattern in your next project. It will reduce server load, improve upload speeds, and keep your backend clean. If you found this article helpful, please like, share, and comment below. I love hearing about your own upload war stories – drop them in the comments. And if you have questions about implementing multipart uploads or handling Lambda post-processing, ask away.
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