Skip to content
SJ
All writing
9 min read

Uploads That Never Touch Your Server

Proxying uploads through your API burns memory and request time for nothing. Presigned URLs move the bytes directly and leave you the part that matters: authorisation.

AWSS3BackendArchitectureSecurity

The default way to accept a file is to post it to your API, which receives it into memory or a temporary file and forwards it to object storage. It works, and it makes your application server a bottleneck for an operation it contributes nothing to.

What proxying actually costs

  • Memory per concurrent upload. Many middleware defaults buffer the whole body before your handler runs. Twenty people uploading 50MB videos is a gigabyte of memory doing nothing but waiting.
  • Request duration tied to the client's connection. A user on a slow mobile connection holds a request open for minutes. On serverless that may exceed the execution limit outright.
  • Double the bandwidth. The bytes arrive at your server and leave again, and you pay for both.
  • Retries are total. A failure at 90% starts from zero, because your server has no resumable protocol.

None of that buys anything. Your server does not need to see the bytes. It needs to decide whether this user may upload, and to know afterwards that they did.

Presigned URLs

A presigned URL is a time-limited, signed permission to perform one operation on one object. Your server creates it — it never handles the file — and the client uploads directly to storage.

// server: authorise, then hand out a narrow permission
const key = `uploads/${user.tenantId}/${randomUUID()}`;

const url = await getSignedUrl(s3, new PutObjectCommand({
  Bucket: BUCKET,
  Key: key,
  ContentType: body.contentType,     // pin the declared type
  ContentLength: body.size,          // pin the declared size
}), { expiresIn: 300 });

await db.uploads.create({ key, userId: user.id, status: "pending" });
return { url, key };

Four things in that snippet are doing security work:

  • The server chooses the key. Never let the client name the object — a client-supplied path is a path traversal or an overwrite of somebody else's file. Generate it, scoped by tenant.
  • Content type and length are signed in. Without them the client can upload anything of any size, and your 5MB avatar limit is decoration.
  • Short expiry. Five minutes is enough to start an upload and short enough that a leaked URL is close to worthless.
  • A pending record exists before the upload. That is what lets you reconcile later.

The bucket itself stays private with public access blocked. The presigned URL is the only way in, and it is narrow and expiring.

Validate after the upload, because the client lied

The critical point: everything the client told you before the upload was a claim. A declared content type of image/png constrains the header the client sends, not the bytes. Someone can upload an executable and declare it an image.

So the upload is not complete when the storage write succeeds. It is complete when your server has verified it — triggered either by the client calling a completion endpoint or, more reliably, by a storage event notification, since a client that navigates away will never call anything.

Verification worth doing:

  • Sniff the actual type from the file's magic bytes and compare it to what was declared. Reject on mismatch.
  • Check the real size from object metadata.
  • Re-encode images rather than trusting them. Decoding and re-encoding strips embedded payloads and EXIF data — which includes GPS coordinates, an unintended privacy leak on user photos.
  • Scan anything that will be downloaded by other users. You are otherwise operating a malware distribution service with authentication.
  • Only then mark the record ready. Nothing references an unverified object.

A note on filenames: keep the user's original name as a metadata field for display, and never use it as the storage key or in a filesystem path. That is where directory traversal and encoding tricks live.

Large files

Past roughly 100MB, a single request is fragile. Multipart upload splits the file into parts that upload independently and are assembled by the storage service.

The flow: your server initiates the multipart upload and returns a presigned URL per part; the client uploads parts in parallel, retrying individual failures rather than the whole file; the client sends the part identifiers back and your server completes the upload. Resumability comes free, because completed parts stay completed.

Set a lifecycle rule to abort incomplete multipart uploads after a few days. Abandoned parts are invisible in the bucket listing and are billed indefinitely — a genuinely common source of unexplained storage cost.

Serving files back

The same reasoning applies in reverse: streaming downloads through your API wastes the same resources.

Public content — product images, public assets — goes behind a CDN with long cache headers and a content hash in the key, so updates are new objects rather than invalidations.

Private content uses presigned GET URLs, generated after your authorisation check, with a short expiry. The important detail is that the URL is a bearer credential — anyone holding it has access until it expires. Keep the window small, and remember that a URL in an email or a chat message is a URL you have shared with more parties than you intended.

For anything genuinely sensitive, signed cookies scoped to a path are better than per-object URLs: the credential is not in the address bar and does not survive being copied out of it.

Orphaned objects, which accumulate quietly

Direct upload means storage and database can disagree, in both directions.

Objects with no record — the user got a URL, uploaded, and closed the tab before completion. The bytes exist and nothing references them, forever, at cost. Handle it with a lifecycle rule that expires objects under an uploads/pending/ prefix after a few days, and move objects to a permanent prefix only once verified.

Records with no object — an upload was authorised and never happened. A periodic job that fails pending records older than their expiry window keeps this from being a permanently confusing state in your data.

And deletion needs to be deliberate. Deleting the database row does not delete the object; the storage cost persists and, more importantly, so does the data — which matters when the deletion was a user exercising a right to erasure. Either delete both in a reconcilable order or record a deletion intent that a job acts on and can be verified.

The short version

Do not put the bytes through your server. Issue a short-lived presigned URL with the key, content type and size pinned by you rather than the client. Treat everything declared before the upload as an unverified claim and check the real bytes after, re-encoding images and scanning anything others will download. Use multipart above roughly 100MB and expire the incomplete ones. Serve public content via CDN and private content via short-lived signed access. And reconcile both directions of orphan, because they cost money and hold data you meant to delete.

Written by Saumya Jain

Full Stack Engineer working on headless commerce, NestJS microservices, and real-time systems. Currently open to remote work.