Our services
If you can think it, we can make it brainsoft.
If you can think it, we can make it brainsoft.
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.
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.
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.
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.
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.
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.
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.
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.