AWSOctober 2, 2026 · 8 min

Handling file uploads at scale with S3 presigned URLs

The default way to handle uploads — POST the file to your API, which streams it to storage — is also the way that falls over. Your server burns memory and bandwidth proxying bytes, big files hit request-size limits, and every upload competes with real API traffic. The scalable pattern takes your server out of the data path entirely using S3 presigned URLs. Here's how it works and how to keep it secure.

The problem with uploading through your API

When files flow through your application server, that server pays for it: memory to buffer, bandwidth in and back out, and a long-held request that ties up a worker. A handful of concurrent large uploads can starve your API of capacity for everything else — and you inherit body-size limits that make big files a special case you have to engineer around.

Presigned URLs: your server authorizes, S3 receives

Instead of receiving the file, your server issues a short-lived, signed URL that grants permission to upload one specific object directly to S3. The browser uploads straight to S3; your server never touches the bytes. It stays in control of who can upload and what — but it's out of the heavy data path.

// Server: mint a scoped, expiring upload URL (never expose AWS keys)
const key = `uploads/${user.tenantId}/${crypto.randomUUID()}`;
const url = await getSignedUrl(s3, new PutObjectCommand({
  Bucket: BUCKET, Key: key,
  ContentType: body.contentType,      // pin the type
  ContentLength: body.size,           // pin the size
}), { expiresIn: 60 });               // short-lived
return { url, key };
// Browser: PUT the file straight to `url` — bytes never hit your API

The security you must not skip

  • Authenticate the request for the URL and scope the object key to the user/tenant so uploads can't collide or overwrite.
  • Constrain the presign: short expiry, pinned content-type and a max size, so a leaked URL is nearly useless.
  • Never trust the client's claimed type/extension — validate server-side after upload (and scan if files are user-shared).
  • Serve files back through short-lived presigned GET URLs or a CDN with signed access — keep the bucket private.

A presigned URL is a capability. Scope it tightly — one object, short expiry, pinned type and size — and a leaked link can't be used to dump arbitrary files into your bucket.

Confirm and process after the fact

Since your server didn't see the upload, close the loop: either the client notifies your API on success, or (more robustly) an S3 event triggers a handler when the object lands. That's where you record it in the database, validate it, generate thumbnails, or kick off processing — asynchronously, off the upload path.

Key takeaways

  • Don't proxy uploads through your API — it wastes memory/bandwidth and caps file size.
  • Issue short-lived presigned URLs so the browser uploads directly to S3.
  • Lock down the presign: auth, tenant-scoped key, short expiry, pinned type and size; validate server-side after.
  • Confirm completion via a client callback or S3 event, and do processing asynchronously.

FAQ

Should file uploads go through my server or straight to S3?

Straight to S3 via presigned URLs for anything beyond trivial sizes. Proxying uploads through your API consumes server memory and bandwidth, ties up workers, and imposes request-size limits. With presigned URLs your server only authorizes the upload; the bytes go directly to S3, which scales far better.

Are presigned S3 URLs secure?

Yes, if scoped correctly. Require authentication to mint one, tie the object key to the user/tenant, set a short expiry, and pin the content-type and max size. Keep the bucket private and serve files back through signed GET URLs or a CDN. Then even a leaked URL can only upload one constrained object briefly.

AA
Ali Asghar

Senior software engineer & technical lead — 6+ years shipping production multi-tenant SaaS, payments and AI integration in Next.js, Node & TypeScript.

Keep reading
Building a render-to-print pipeline with BullMQ, Puppeteer and content-hash caching →Deploying microfrontends on AWS with Module Federation, S3 and CloudFront →Zero-downtime database migrations: shipping schema changes without a maintenance window →