Our services

If you can think it, we can make it brainsoft.

Handling file uploads without hosting your own storage

Written By: BrainSoft In Backend

Someone asks for avatar uploads. Then it is PDF attachments. Then a customer wants to send 200 MB video files from a phone on hotel wifi. The naive version of this feature is a multipart POST to your API, the file lands in a temp directory, and now your app server is also a storage server. Disk fills up, requests tie up worker threads, and you are suddenly responsible for backups, retention and access control on data you never wanted to hold.

The fix is to keep files out of your application entirely. Your backend issues a short-lived presigned URL, the browser or mobile client uploads straight to object storage, and your database stores only the key and metadata. This walks through that flow end to end, including the parts people get wrong.

Why the file should never hit your server

An upload proxied through your API costs you three times. You pay for the bytes coming in, the bytes going out to storage, and the CPU time spent streaming them. More importantly, a slow client holds a connection and often a worker process for the whole transfer. One user on a bad connection can degrade the experience for everyone else on that instance.

With a presigned URL, the client talks to the storage provider directly. Your server does a few milliseconds of signing work and returns. The storage provider handles retries, chunking and durability. You get to treat storage as a dependency instead of an infrastructure project.

  • No disk to monitor, resize or back up on the app side.
  • Upload throughput scales with the provider, not with your instance size.
  • Access control lives in one place: your signing logic.
  • You can delete an object and its metadata in a single request.

The flow, concretely

It is three requests. The client asks your API for permission to upload, uploads to the returned URL, then tells your API the upload finished so you can persist the key. The middle request never touches your infrastructure.

POST /uploads/presign   -> { uploadUrl, objectKey, expiresIn }
PUT  uploadUrl          -> raw bytes, straight to storage
POST /uploads/complete  -> { objectKey }  (server verifies, saves row)

The presign endpoint is where you make decisions. Cap the size, restrict the content type, and scope the key so one user cannot overwrite another user's files.

const key = `users/${userId}/${crypto.randomUUID()}.pdf`;

const uploadUrl = await getSignedUrl(s3, new PutObjectCommand({
  Bucket: process.env.BUCKET,
  Key: key,
  ContentType: 'application/pdf',
  ContentLength: 10 * 1024 * 1024,
}), { expiresIn: 300 });

Note the random UUID in the key. User-supplied filenames are a bad primary identifier: they collide, they leak information, and they invite path traversal if you ever concatenate them. Keep the original name in the database as a display label and use the generated key for everything else.

Downloads and access control

Do not make the bucket public and hope nobody guesses the keys. Keep it private and sign GET requests too, with a short expiry, after your own authorization check has passed.

const file = await db.files.findOne({ id, ownerId: session.userId });
if (!file) return res.status(404).end();

const url = await getSignedUrl(s3, new GetObjectCommand({
  Bucket: process.env.BUCKET,
  Key: file.objectKey,
}), { expiresIn: 60 });

That ownership check is the whole security model. The storage provider does not know who your users are, so every read decision has to happen in your code before you sign anything. If you need shared links, sign a token with an expiry and store it against the file rather than loosening the bucket policy.

Two things people skip and regret. First, verify the object exists with a HEAD request in the complete step, because a client can claim success without uploading. Second, scan untrusted files before serving them back, which is a small piece of our services work when uploads are user-facing.

Practical details that bite later

  • Set a CORS policy on the bucket that allows PUT and the exact origins you serve from. A wildcard works in dev and fails in prod.
  • Large files need multipart upload. Sign each part separately and complete the multipart on the client, or use the provider's SDK helper.
  • Store the content type and size in your database at complete time. Do not trust what the client reported at presign time.
  • Add lifecycle rules to delete orphaned objects that were uploaded but never completed.
  • Keep the bucket in the same region as your app to avoid egress latency and cost on every download.

None of this requires a storage team. It requires deciding, once, that your application server is not a file server. If you want help wiring this into an existing stack, get in touch.

Frequently asked questions

Do I still need a backend if uploads go straight to storage?

Yes. The backend issues presigned URLs, checks permissions, records metadata and signs download links. It just never handles the file bytes, which is the part that made it expensive to run.

How long should a presigned URL be valid?

Minutes, not hours. Five minutes is plenty for a normal upload and limits the damage if a URL leaks. For very large multipart uploads, sign each part with its own short expiry instead of extending one URL.

Can I use this with any object storage provider?

The pattern works with S3, Google Cloud Storage, Azure Blob and any S3-compatible service. The signing SDK differs, but the three-request flow and the ownership check stay the same.


#Backend