October 10, 2026
Your Backend Doesn’t Have to Carry Every File to S3
What I learned about direct uploads, presigned URLs, and the checks that still belong on the server.

By M Awais Nazir
7 min read
As a junior developer, I handled file uploads the same way every time. The frontend sent a file to my backend. My backend uploaded it to S3. I saved the file details in the database and moved on.
It worked. I never had much reason to question it.
While working on a production system at EndpointClients, I learned another approach: let the browser upload the file directly to S3, while the backend decides whether that upload is allowed.
That last part matters. The browser doesn't get your AWS secret access key or general access to your bucket. It gets permission to make a particular request, for a limited time.
Once I understood that split, the architecture made a lot more sense.
The file can take a shorter route
In the approach I started with, the file takes two application-level hops: browser to backend, then backend to S3.
With a direct upload, the browser first asks the backend for permission. The backend checks the user and creates a presigned URL. The browser uses that URL to send the file straight to S3.
The URL is a signed request for a specific bucket, object key, HTTP operation, and expiry. A URL for uploading one object doesn't also let someone list the bucket or download other files. The signing role must already have permission for the operation. AWS explains the permission model here.
Think of an object key as the file's address inside the bucket. Something like incoming/tenant-123/a-random-id is a key; it doesn't have to be the original filename.
My backend still owns the decisions. It just doesn't need to receive and forward every byte.
Why add another request?
At first, this looked like extra work. There's a request for permission, an upload, and usually a request to tell the application that the upload finished.
But those small requests can remove a much bigger burden.
Less traffic through the backend. For a 2 MB file, the traditional route makes the backend receive about 2 MB and send about 2 MB onward, ignoring protocol overhead. Direct upload removes that file payload from the backend's path. The user still uploads roughly 2 MB either way. This isn't a promise that your bandwidth bill will be cut in half; billing depends on your hosting provider and network path.
Fewer request-size problems. Vercel currently documents a 4.5 MB request and response payload limit for Vercel Functions. A larger upload routed through one of those functions can fail with 413 FUNCTION_PAYLOAD_TOO_LARGE. Sending the file directly to S3 avoids that particular function-body limit. Your own file-size policy and S3's limits still apply. Vercel Functions limits.
Less work tied to slow uploads. A slow connection can keep an upload request open for a while. Direct uploads let the application server concentrate on authentication, authorization, and upload records. A well-written proxy can stream files without buffering them entirely in memory, but it still handles their traffic and connections.
This is especially useful when many people upload at once, or when files are much larger than an ordinary API request.
The flow I would build
There are three parts:
- Authorize: the backend checks the user, chooses an object key, records a pending upload, and returns a short-lived URL.
- Transfer: the browser sends the file bytes directly to S3.
- Verify: the backend looks up the upload record and checks S3 before accepting the file into the application.
Let's put the idea into code using TypeScript, Node.js, and AWS SDK v3. These are focused examples; the authentication and database layers belong to your application.
Install the SDK packages on the server:
npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presignernpm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner1. Create the URL on the backend
Before calling this helper, an authenticated API route should validate the request at runtime, check the user's permission to attach a file to the relevant resource, enforce quotas, and create a database record with a server-generated key. TypeScript types alone don't validate incoming JSON.
Store the owner, destination resource, expected size, allowed content type, and original filename in that record. The client should receive an opaque upload ID; it shouldn't choose an arbitrary S3 key.
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
const bucket = process.env.S3_BUCKET;
const region = process.env.AWS_REGION;
if (!bucket || !region) throw new Error("Missing S3 configuration");
const s3 = new S3Client({
region,
requestChecksumCalculation: "WHEN_REQUIRED",
});
type PendingUpload = {
id: string;
key: string; // Generated by our backend
contentType: string; // Validated against our allowlist
};
export async function signUpload(upload: PendingUpload) {
const command = new PutObjectCommand({
Bucket: bucket,
Key: upload.key,
ContentType: upload.contentType,
IfNoneMatch: "*",
});
const url = await getSignedUrl(s3, command, {
expiresIn: 180,
signableHeaders: new Set(["content-type", "if-none-match"]),
});
return {
uploadId: upload.id,
url,
headers: {
"Content-Type": upload.contentType,
"If-None-Match": "*",
},
};
}import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
const bucket = process.env.S3_BUCKET;
const region = process.env.AWS_REGION;
if (!bucket || !region) throw new Error("Missing S3 configuration");
const s3 = new S3Client({
region,
requestChecksumCalculation: "WHEN_REQUIRED",
});
type PendingUpload = {
id: string;
key: string; // Generated by our backend
contentType: string; // Validated against our allowlist
};
export async function signUpload(upload: PendingUpload) {
const command = new PutObjectCommand({
Bucket: bucket,
Key: upload.key,
ContentType: upload.contentType,
IfNoneMatch: "*",
});
const url = await getSignedUrl(s3, command, {
expiresIn: 180,
signableHeaders: new Set(["content-type", "if-none-match"]),
});
return {
uploadId: upload.id,
url,
headers: {
"Content-Type": upload.contentType,
"If-None-Match": "*",
},
};
}The SDK resolves credentials on the server. In production, use a suitably restricted IAM role through your hosting environment's supported credential mechanism. Keep the bucket private and restrict the signing role to the upload prefix it needs.
The two headers are explicitly included in the signature, so the browser must send the values returned by the server. The SDK supports this through signableHeaders.
If-None-Match: * adds a useful guard: this PUT must not replace an object that already exists at the key. It is an S3 conditional write, not a general guarantee that a presigned URL is single-use. Don't delete the object and reuse its key while the URL is still valid. AWS conditional writes.
The checksum setting keeps this small example from automatically adding an optional SDK upload checksum when the backend has no file body. For explicit end-to-end integrity, calculate a checksum over the actual file and include it in the signed upload contract. A checksum detects changed bytes; it doesn't tell you whether a file is safe. AWS checksum behavior.
2. Send the file from the browser
The API paths below are application routes you implement. They assume same-origin cookie authentication, with your application's CSRF protection applied to the state-changing requests.
async function uploadFile(file: File, projectId: string) {
const ticketResponse = await fetch("/api/uploads", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
projectId,
name: file.name,
size: file.size,
type: file.type || "application/octet-stream",
}),
});
if (!ticketResponse.ok) throw new Error("Upload not authorized");
const ticket = await ticketResponse.json();
const uploadResponse = await fetch(ticket.url, {
method: "PUT",
headers: ticket.headers,
body: file,
credentials: "omit",
});
if (!uploadResponse.ok) {
throw new Error(`S3 upload failed: ${uploadResponse.status}`);
}
const completeResponse = await fetch("/api/uploads/complete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ uploadId: ticket.uploadId }),
});
if (!completeResponse.ok) {
throw new Error("File uploaded, but confirmation failed");
}
return completeResponse.json();
}async function uploadFile(file: File, projectId: string) {
const ticketResponse = await fetch("/api/uploads", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
projectId,
name: file.name,
size: file.size,
type: file.type || "application/octet-stream",
}),
});
if (!ticketResponse.ok) throw new Error("Upload not authorized");
const ticket = await ticketResponse.json();
const uploadResponse = await fetch(ticket.url, {
method: "PUT",
headers: ticket.headers,
body: file,
credentials: "omit",
});
if (!uploadResponse.ok) {
throw new Error(`S3 upload failed: ${uploadResponse.status}`);
}
const completeResponse = await fetch("/api/uploads/complete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ uploadId: ticket.uploadId }),
});
if (!completeResponse.ok) {
throw new Error("File uploaded, but confirmation failed");
}
return completeResponse.json();
}For a presigned PUT, send the raw File. Wrapping it in FormData changes the request body. A presigned POST is a different flow that does use form fields.
Also notice the two failure points. The file may reach S3 even if the final API call fails. Make the completion endpoint idempotent so the client can retry confirmation using the same upload ID. With the conditional PUT above, a repeat upload to an existing key is rejected; check the upload's status instead of blindly sending the file again.
3. Verify before calling it done
On /api/uploads/complete, authenticate again and load the pending record by upload ID and owner. Recheck permission on the destination resource. Use the key from that record rather than accepting a key supplied by the browser.
A first check can look like this, using the same server-side s3 client and bucket:
import { HeadObjectCommand } from "@aws-sdk/client-s3";
async function inspectStoredUpload(record: {
key: string;
expectedSize: number;
}) {
const object = await s3.send(new HeadObjectCommand({
Bucket: bucket,
Key: record.key,
}));
if (object.ContentLength !== record.expectedSize) {
throw new Error("Stored file size does not match upload record");
}
return object; // Metadata check passed; content checks still follow.
}import { HeadObjectCommand } from "@aws-sdk/client-s3";
async function inspectStoredUpload(record: {
key: string;
expectedSize: number;
}) {
const object = await s3.send(new HeadObjectCommand({
Bucket: bucket,
Key: record.key,
}));
if (object.ContentLength !== record.expectedSize) {
throw new Error("Stored file size does not match upload record");
}
return object; // Metadata check passed; content checks still follow.
}The declared size must already have passed your application's maximum-size check when the upload record was created. HeadObject lets you compare that claim with S3's stored length. The backend needs the relevant read permission for this operation. A missing object or an access error must not become a successful completion. HeadObject reference.
This still doesn't prove that a file claiming to be a PDF is a valid PDF. ContentType is metadata. Inspect the bytes, parse or decode the allowed formats, and scan files where your application requires it. Keep them private and pending until those checks pass.
For stronger protection against changes between inspection and use, validate a specific S3 version and keep that version ID with the accepted file. Your validation worker and download path must use that same version. Otherwise, prevent changes to the object throughout that process.
The details I would not skip
Configure CORS for the browser. For the PUT example above, a bucket CORS rule can be as small as:
[
{
"AllowedOrigins": ["https://app.example.com"],
"AllowedMethods": ["PUT"],
"AllowedHeaders": ["content-type", "if-none-match"],
"MaxAgeSeconds": 300
}
][
{
"AllowedOrigins": ["https://app.example.com"],
"AllowedMethods": ["PUT"],
"AllowedHeaders": ["content-type", "if-none-match"],
"MaxAgeSeconds": 300
}
]Replace the origin with yours and add any headers your actual signed request needs. CORS lets the browser make the cross-origin request; it doesn't grant S3 access or replace authorization. S3 handles the browser's OPTIONS preflight; you list the intended operation, PUT, in this rule. AWS CORS configuration.
Treat the URL like a temporary credential. Anyone holding it can make the authorized request. Avoid putting it in logs, analytics events, or screenshots. Three minutes is an example, not a universal setting; choose an expiry that allows the client to start and reasonably retry. Temporary AWS credentials can expire before the URL's configured deadline. AWS presigned URL expiration.
A declared size is not an upload limit. Checking file.size in the browser or the JSON sent to your API doesn't stop a modified client from sending more bytes. The PUT example checks the stored size afterward. If you need S3 to enforce a size range during submission, consider a presigned POST policy with a content-length-range condition. AWS POST policies.
Plan for abandoned uploads. A user can close the tab after S3 receives the file. Track pending records, rate-limit URL creation, and clean up files that never become accepted. Cleanup must respect active URLs and validation jobs. Otherwise you can create a race by deleting an object while a retry can still write to its key.
Would I use this for every upload?
No. For small files at low volume, sending them through the backend can be entirely reasonable. A proxy can also be the right choice when the system must inspect or transform bytes before they enter its storage workflow.
For larger files or unreliable connections, I would look at S3 multipart uploads. The client uploads parts independently, so a failed part can be retried without resending the entire file. That also means managing upload IDs, signing individual parts, completing the upload, and aborting abandoned ones. Multipart upload and presigned URLs work together. AWS multipart upload overview.
What changed for me was realizing that authorizing an upload and carrying its bytes are separate jobs. My backend can stay in charge of the file without being the route the file has to travel through.
I wish I'd understood that earlier.
How are you handling uploads in your projects: through the backend, direct to storage, or multipart?
If you'd like to connect or see what I'm building, you can find me at awaisnazir.tech.