Piping a file through your backend to upload it to S3 is paying the same toll twice. The file travels from the browser to your API, consumes your server's memory and bandwidth, and then travels again from your API to S3. On a Lambda, you also hit the payload limit; on Fargate, you're occupying a container to act as a pipe. The alternative I've used for years: the backend signs a temporary URL with surgical permissions, the browser uploads directly to S3, and your API only processes metadata. I chose this pattern in the dashboards I build for clients because it scales without touching infrastructure; in exchange, the flow has more steps and the security has to be designed carefully, because a poorly scoped presigned URL is an open door. This article is the full flow.
The flow in three steps
The pattern has three actors: the browser, your API, and S3. The API never sees the file.
// 1. The backend generates the signed URL (Lambda or a regular endpoint)
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
const s3 = new S3Client({});
export async function createUploadUrl(userId: string, contentType: string) {
if (!ALLOWED_TYPES.has(contentType)) {
throw new Error('File type not allowed');
}
const key = `uploads/${userId}/${crypto.randomUUID()}`;
const url = await getSignedUrl(
s3,
new PutObjectCommand({
Bucket: process.env.UPLOADS_BUCKET,
Key: key,
ContentType: contentType,
ContentLength: undefined, // limit via POST policy conditions if needed
}),
{ expiresIn: 300 } // 5 minutes, not one more
);
return { url, key };
}
The browser does a direct PUT to that URL with the file as the body. When it finishes, it notifies your API with the key, and there you register the file in PostgreSQL via Prisma, trigger async processing, or whatever applies.
The three security decisions that matter
The key is generated by the server, always. If you let the client pick the file name, you're letting it pick where it writes in your bucket. The key includes the authenticated userId and a UUID: the client contributes not a single character.
Short expiration and pinned content-type. Five minutes is enough to start any upload. And signing the ContentType into the URL forces what's uploaded to match what was declared — it doesn't stop someone from renaming an executable to .jpg, but it closes the easy path.
The uploads bucket is not the final bucket. Everything lands in a staging bucket with a lifecycle rule that deletes objects after 24 hours. A later process — in my case a Lambda triggered by the s3:ObjectCreated event — validates the file for real (magic bytes, size, scanning if applicable) and moves it to the definitive bucket. Whatever nobody validates self-destructs on its own.
💡 A presigned URL is not "access to S3": it's ONE operation, on ONE key, during ONE interval. If your signed URL allows more than that, you haven't understood the pattern — you've opened a hole with extra steps.
The confirmation event: don't trust the client
The classic mistake with this pattern is marking the file as "uploaded" when the browser says it finished. The browser lies: the tab closes, the network fails, the user cancels. The source of truth is S3, not the client.
That's why the database record has two phases: the API creates the row in pending state when signing the URL, and the ObjectCreated event Lambda moves it to available when the object actually exists. Rows stuck in pending for more than an hour get cleaned up by a job. The client can notify to speed up the UX, but its notification is an optimization, never the truth.
When not to use this pattern
As always, there are trade-offs. If files are small (JSON, avatars of a few KB) and volume is low, going through the backend is simpler and simplicity wins. If you need to transform the file inline before storing it (resizing, synchronous transcoding), the backend has to see it anyway. And if your frontend is an offline-first PWA, direct upload complicates the sync queue: there I sometimes prefer to queue the file locally and upload it from a service worker when there's network, which changes the whole design.
For everything else — documents, images, anything that weighs megabytes and arrives with concurrency — letting S3 do the heavy lifting is one of the few architecture decisions I've never regretted.