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 APIThe 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.