Insights / Security
Direct-to-cloud file uploads with presigned URLs: skip the server, keep the validation
Let the browser upload straight to object storage with a short-lived presigned URL, while your server keeps control of signing, quotas, and validation.
Kiran Bandarupalli · 6 Nov 2026 · 6 min read

The default way to accept a file upload is to have the browser send it to your application server, which reads the bytes, validates them, and forwards them to object storage. It works fine for a contact form attachment. It starts to hurt when uploads get large, concurrent, or frequent, because your web tier has quietly become a file-transfer proxy: every megabyte passes through your server's memory, your request timeouts, and your bandwidth bill, twice. Direct-to-cloud upload flips the flow. The server issues a short-lived signed URL, the browser uploads straight to storage, and the server validates the object after the fact.
This is not a shortcut around security. It is a different place to enforce it. The signing endpoint becomes the boundary, and the validation step after the upload becomes the gate.
How a presigned upload actually works
The flow has four steps, and none of them involves your server handling the file itself.
- Request: the browser asks your API for an upload URL, naming the file's type and size.
- Sign: the server checks the user, the file type, and the size limit, then generates a presigned PUT URL scoped to one object key, one content type, and a few minutes of expiry.
- Upload: the browser PUTs the file directly to object storage. Your server is not in the path.
- Confirm and validate: storage notifies your server, or the browser reports completion, and the server inspects the stored object before marking it usable.
Steps one and four are where your rules live. Step two is a signature, not a permission slip handed out blindly. The URL must be narrow — one key, one content type, one short window — and the file must be treated as untrusted until step four passes.
What the server still controls
Moving bytes out of your server does not move the decisions out of it. The signing endpoint should enforce everything it enforced when it accepted uploads directly — auth, a type allowlist, a size cap, and per-user quota. Two rules are specific to this pattern.
First, never let the client choose the object key. A client-supplied key is a path-traversal and overwrite vector: one user can overwrite another's file or write into a prefix your application trusts. Generate the key server-side, namespaced by user and by upload, and keep the original filename only as metadata. Second, bind the content type into the signature. A presigned URL that accepts any content type lets an attacker upload an executable where your application expects an image. The same discipline applies to a secure file upload pipeline that runs through your server — the checks do not disappear, they move.
Expiry deserves a number, not a feeling. Five to fifteen minutes is enough for a browser to start an upload, and short enough that a leaked URL is useless within the hour. If your users upload from slow connections, issue the URL when the upload actually starts, or switch very large files to a resumable upload flow that can re-sign a part without reopening the whole window.
Validate after the upload, before the file is usable
A file that reaches your bucket is not a file your application should trust. Keep uploads in a quarantine prefix that nothing public can read, and let a background job do the inspection: check the magic bytes rather than the extension, run a malware scan and quarantine pass, and strip metadata you do not want to keep. Only then move the object to its final prefix or mark the record as ready. The same image validation rules apply here as they do to server-side uploads — direct upload changes the transport, not the threat model.
That validation step belongs in a queue, not in the request that issued the URL. A scan that takes seconds should not hold a browser waiting, and a scan that fails should not leave the application guessing. The pattern is the one described in background jobs with retries and idempotency: a storage event or completion callback enqueues a job, the job inspects the object exactly once, and the result flips the upload record from pending to ready or rejected. If the job never runs, the file stays quarantined and invisible — the safe default. Once a file is approved, serve it through signed download URLs with expiry and access control rather than a public bucket path, so a file that was private when uploaded does not become public because someone guessed its key.
Issuing the URL on the server
The server code is small. The important part is not the SDK call, it is the checks before it and the record you store after it.
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
const s3 = new S3Client({ region: process.env.AWS_REGION });
async function createUploadUrl(user, file) {
// Enforce your policy here: auth, type allowlist, size cap, quota.
assertAllowedType(file.type);
assertWithinSizeLimit(file.size);
await assertQuotaRemaining(user.id);
const key = `uploads/${user.id}/${crypto.randomUUID()}/${file.name}`;
const command = new PutObjectCommand({
Bucket: process.env.UPLOADS_BUCKET,
Key: key,
ContentType: file.type,
ContentLength: file.size,
});
const url = await getSignedUrl(s3, command, { expiresIn: 600 });
await saveUploadRecord({ userId: user.id, key, status: "pending" });
return { url, key };
}
On the client, the upload is a plain PUT with the declared content type — no SDK, no credentials in the browser:
const { url } = await fetch("/api/uploads/sign", {
method: "POST",
body: JSON.stringify({ type: file.type, size: file.size }),
}).then((r) => r.json());
await fetch(url, { method: "PUT", body: file, headers: { "Content-Type": file.type } });
await fetch("/api/uploads/confirm", { method: "POST", body: JSON.stringify({ key }) });
Note what the browser never receives: storage credentials. The presigned URL carries the signature, and the signature carries the constraints. If a URL leaks, it authorises one upload of one type to one object for ten minutes, and the object still lands in quarantine.
When direct upload is the wrong call
The pattern is not universal. If files are small and your server already handles them comfortably, adding presigned URLs, quarantine prefixes, and a validation queue is complexity you do not need — a straightforward file upload security checklist applied to the existing flow is the better spend. If a file must be transformed before it is stored — resized, transcoded, redacted — the server or a processing pipeline has to touch the bytes anyway.
Go-live checklist
- Signing endpoint checks auth, type allowlist, size cap, and quota before signing anything.
- Object keys are generated server-side and namespaced per user; the client never chooses a key.
- Presigned URLs bind one content type and expire in 5–15 minutes.
- Uploads land in a private quarantine prefix; nothing serves them until validation passes.
- A queued job checks magic bytes, scans for malware, and strips unwanted metadata before the file goes live.
- Downloads use signed URLs with expiry — never a public bucket path.
- Failed or abandoned uploads are cleaned up by a lifecycle rule, so quarantine does not grow forever.
- Upload progress, errors, and validation failures are observable, so a stuck upload is diagnosable rather than silent.
Direct-to-cloud upload looks like a performance optimisation and turns out to be an architecture decision. The win is not only that uploads stop passing through your server; it is that the responsibilities become explicit — the signing endpoint decides, storage transports, and a queued job verifies. If your application accepts files from users and the upload path is starting to show up in your scaling conversations, this is the change to make before the next traffic spike makes it for you. If you want help applying it to your own application, talk to us — file handling is part of the cloud and DevOps work we do for business applications.