File Uploads From a Static Form: Direct-to-Storage Patterns
How to do a direct-to-storage form file upload from a static site: presigned POST vs PUT, CORS traps, multipart, quarantine scanning and upload progress UX.
A client sends over a careers page. Simple enough: name, email, message, CV attachment. You wire the form to a serverless function, test it with a 200KB PDF, ship it. Two weeks later the client forwards an email: “the designer can’t submit her portfolio.” Her portfolio is a 9MB PDF. Your function returned a 413 before your code ever ran.
That ceiling is not negotiable and it is lower than most people think. Vercel’s serverless functions cap request bodies at 4.5MB. AWS Lambda’s synchronous invoke payload is 6MB, and because API Gateway and Netlify base64-encode binary bodies, your real usable limit is closer to 4.5MB there too. Cloudflare Workers are more generous on paper but you still pay CPU time to shovel bytes you don’t need to touch.
The fix has been the same since 2015 and it still surprises people: the browser uploads straight to object storage, and your backend never sees the bytes. Done properly, a static form file upload handles a 2GB video with no server, no timeout and no memory pressure. Done badly, you’ve published a world-writable bucket. Here’s the version we actually deploy.
- A presigned PUT URL does not constrain file size unless you sign
Content-Lengthexactly. Presigned POST policies with acontent-length-rangecondition do, which is why they’re the better default for browser uploads. - Upload to a disposable quarantine bucket with a 24 hour lifecycle rule, validate magic bytes server-side after the fact, then copy to the permanent bucket. Never trust the client-declared MIME type or filename.
- Bind uploads to a submission with a short-lived signed token containing an allowed key prefix, then list objects under that prefix on the server. Don’t let the form post an arbitrary list of object keys.
fetch()still gives you no upload progress events in 2026. XMLHttpRequest’supload.onprogressis the only cross-browser option, so a thin XHR wrapper is correct code, not legacy code.- Set an
AbortIncompleteMultipartUploadlifecycle rule on day one. Orphaned multipart parts are invisible in the console and you pay storage on them indefinitely.
Why proxying the upload through a function fails
The obvious architecture is the wrong one. Browser posts multipart/form-data to your function, function parses it, function writes to S3. It works in dev with small files and falls over in production in four separate ways.
Payload limits kill it first. Then execution time: a user on a 5Mbps upstream connection pushing 20MB needs about 32 seconds, and your function is billed and timing out for every one of those seconds while doing nothing but copying buffers. Memory is third, because most multipart parsers buffer the whole part before handing it to you. Last: you now have a public endpoint that accepts arbitrary binary data, which is a fairly attractive thing to point a script at.
Direct-to-storage removes all four problems. Your function does one cheap, fast thing: it authorises an upload. The bytes travel from the browser to S3, R2, Supabase Storage or wherever over a path that’s already engineered for exactly this.

The three-step flow for a static form file upload
Every working implementation follows the same three steps, whatever the storage provider.
- Sign. The browser asks your endpoint for permission to upload a specific file. It sends the filename, size and type. Your endpoint decides yes or no, generates a credential scoped to one object key, and returns it with a submission token.
- Upload. The browser sends the bytes directly to storage using that credential. Your infrastructure is not involved.
- Submit. The form posts the normal fields plus the submission token. Your backend verifies the token, confirms the objects exist, validates them, and records the submission.
Step three is the one people skip, and it’s where the data model breaks. If the form submit includes a JSON array of object keys the client claims it uploaded, an attacker can rewrite that array to point at someone else’s keys. Issue the prefix server-side instead.
// /api/sign (Cloudflare Worker, aws4fetch)
import { AwsClient } from 'aws4fetch';
const MAX_BYTES = 15 1024 1024;
const ALLOWED = new Set(['application/pdf', 'image/jpeg', 'image/png']);
export default {
async fetch(req, env) {
const { filename, size, type, sid } = await req.json();
// sid is a UUID the client generated for this form session.
// We only ever let it write under its own prefix.
if (!/^[0-9a-f-]{36}$/.test(sid)) return bad('bad session');
if (size > MAX_BYTES || size < 1) return bad('too large');
if (!ALLOWED.has(type)) return bad('type not allowed');
const safe = filename.replace(/[^\w.\-]/g, '_').slice(-120);
const key = incoming/${sid}/${crypto.randomUUID()}-${safe};
const aws = new AwsClient({
accessKeyId: env.R2_KEY,
secretAccessKey: env.R2_SECRET,
service: 's3',
region: 'auto'
});
const signed = await aws.sign(
new Request(${env.R2_ENDPOINT}/${env.BUCKET}/${key}, { method: 'PUT' }),
// Content-Length is in signedHeaders, so the browser must send
// exactly this many bytes or S3/R2 rejects the request.
{ aws: { signQuery: true, allHeaders: true, datetime: undefined },
headers: { 'content-type': type, 'content-length': String(size) } }
);
return Response.json({ url: signed.url, key });
}
};
function bad(m) { return new Response(m, { status: 400 }); }
Presigned PUT versus presigned POST
This is the decision that matters most, and most tutorials get it wrong by omission.
A presigned PUT URL is a signed GET-style URL you issue and the browser PUTs bytes to. Trivially easy to generate. The catch: the signature covers the method, the key and whichever headers you listed in signedHeaders. If you don’t sign Content-Length, the URL accepts a file of any size up to the provider’s single-object limit. Someone who grabs that URL from devtools can push 5GB through it. Signing the exact length fixes it, but introduces rigidity: the browser must send precisely that byte count, so you cannot transform the file after signing (no last-second image compression, no appending anything).
A presigned POST is a signed policy document plus form fields. You declare conditions, and S3 enforces them before accepting a single byte:
import { createPresignedPost } from '@aws-sdk/s3-presigned-post';
const { url, fields } = await createPresignedPost(s3, {
Bucket: 'acme-uploads-incoming',
Key: key,
Conditions: [
['content-length-range', 1024, 15 1024 1024], // 1KB to 15MB, enforced by S3
['starts-with', '$Content-Type', 'application/pdf'],
['starts-with', '$key', incoming/${sid}/]
],
Fields: { 'Content-Type': 'application/pdf' },
Expires: 300 // seconds; checked when the request starts, not when it finishes
});
A range instead of an exact value, plus prefix enforcement on the key. That’s materially safer, and it’s what we default to on AWS. One caveat worth knowing before you architect around it: presigned POST policy support is an S3 feature that S3-compatible providers implement unevenly. Cloudflare R2’s presigned URL support is solid for PUT and GET; check the current state of POST policies for your provider before committing, because discovering the gap after you’ve built the UI is a bad afternoon.
If you’re on R2 with presigned PUT only, sign Content-Length and accept the rigidity. It’s a real constraint, not a theoretical one.
CORS, content types and the things that cost us hours
Three recurring failures, all avoidable.
The signed Content-Type must match byte for byte. You sign image/jpeg, the browser sends image/jpeg, fine. But Safari and Chrome disagree about HEIC, some Windows machines report application/octet-stream for PDFs when the file association is missing, and a drag-dropped file from certain apps arrives with an empty type. Always sign whatever file.type the browser reported for that exact File object, and treat the empty string as a valid value you need to handle rather than a bug.
CORS needs ExposeHeaders for multipart. Without exposing ETag, your JavaScript can read the 200 response but not the ETag inside it, and multipart completion requires those ETags. The error you get is a null, not a CORS message.
[
{
"AllowedOrigins": ["https://acme.com"],
"AllowedMethods": ["PUT", "POST"],
"AllowedHeaders": ["content-type", "content-length", "x-amz-*"],
"ExposeHeaders": ["ETag", "Location"],
"MaxAgeSeconds": 3600
}
]
Every PUT with a Content-Type header triggers a preflight. That’s an extra round trip per file. On a 20-file batch from a mobile connection in Jakarta it’s noticeable. MaxAgeSeconds: 3600 makes the browser cache the preflight result. It’s a one-line change most people never make.
Large files, multipart and resumability
A single PUT tops out at 5GB on S3. Realistically you should switch to multipart well before that, around 100MB, because a single PUT has no resume: one dropped connection at 95 percent and the whole thing restarts from zero.
Multipart parts are minimum 5MB (except the last), maximum 10,000 parts. The orchestration is three calls: CreateMultipartUpload server-side, N presigned UploadPart URLs the browser hits in parallel, then CompleteMultipartUpload server-side with the part numbers and ETags. You can presign all the part URLs in one go for files under a few hundred MB, or issue them in batches of 10 for bigger ones.
Unless you enjoy this problem, don’t write it yourself. Uppy’s AWS S3 plugin handles multipart, retries, pause and resume, and expects a small companion endpoint you can implement in about 80 lines. For genuinely resumable uploads across page reloads, the tus protocol is the mature answer and Supabase Storage speaks it natively. Video is its own category: push it to Mux or Cloudflare Stream with their direct creator upload URLs rather than reinventing transcoding.
Set this lifecycle rule now, before you forget:
aws s3api put-bucket-lifecycle-configuration \
--bucket acme-uploads-incoming \
--lifecycle-configuration '{
"Rules": [
{ "ID": "abort-orphan-parts", "Status": "Enabled", "Filter": {},
"AbortIncompleteMultipartUpload": { "DaysAfterInitiation": 1 } },
{ "ID": "purge-unclaimed", "Status": "Enabled",
"Filter": { "Prefix": "incoming/" },
"Expiration": { "Days": 1 } }
]
}'
Abandoned multipart parts don’t appear in the bucket listing. They do appear on the bill.
Validation, quarantine and never trusting the client
Everything the browser told you is a suggestion. The declared MIME type, the file extension, the filename: all attacker-controlled. Your signing endpoint filters on them to give honest users a fast rejection, not because they’re a security boundary.
The pattern we use on every client build with uploads is two buckets. Files land in acme-uploads-incoming, which is private, has the 24-hour expiry above, and has no public read path at all. An event notification fires a function that reads the first 512 bytes, checks the magic number against the claimed type, runs ClamAV or a scanning service if the client’s compliance posture requires it, strips EXIF from images, and only then copies the object to acme-uploads-clean. The copy is where the real key gets assigned and the database row gets written.
On the submit step, verify the token rather than the file list:
// On submit: the browser sends { sid, token, name, email, message }
const payload = await verifyJwt(token, env.SIGNING_SECRET); // { sid, exp }
if (payload.sid !== body.sid) return bad('token mismatch');
// Authoritative list comes from storage, not from the form.
const objects = await listObjects(incoming/${payload.sid}/);
if (objects.length > 5) return bad('too many files');
The token is issued by the sign endpoint on first call and expires in 30 minutes. It means the only thing the form can do is claim the files it was actually authorised to upload.
Rate limiting belongs on the sign endpoint, not on storage. One IP, 20 signatures per hour, and a Turnstile or hCaptcha token required before the first signature of a session. That single gate stops nearly all automated abuse, and it composes well with the rest of the anti-spam measures that still work in 2026.
Getting the html file upload UX right
The accept attribute on an <input type="file"> is a filter hint for the OS picker. It is not validation and it’s trivially bypassed, so treat it purely as a convenience.
Progress is where people go wrong. fetch() has no upload progress event. Request streaming with duplex: 'half' exists in Chromium but requires HTTP/2, isn’t universally supported, and still doesn’t give you a progress callback. For any presigned upload where you want a real progress bar, XHR is the correct tool:
function upload(file, url, onProgress) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('PUT', url);
xhr.setRequestHeader('Content-Type', file.type); // must match the signature
xhr.upload.onprogress = e => {
if (e.lengthComputable) onProgress(e.loaded / e.total);
};
xhr.onload = () => xhr.status reject(new Error('network'));
xhr.send(file);
});
}
Downscale images in the browser before uploading. A modern phone camera produces 8MB to 12MB JPEGs; resizing to 2000px on the long edge with createImageBitmap and OffscreenCanvas typically lands you between 250KB and 600KB. That’s a 20x reduction in upload time on mobile, and it’s often the difference between a completed submission and an abandoned one. Only do it when you’ve signed a POST policy with a size range, since a PUT signed with an exact Content-Length will reject the resized file.
Accessibility gets skipped on drag-drop zones almost universally. The rules are short: keep a real labelled <input type="file"> in the DOM (visually hidden is fine, display: none is not, because it drops out of the tab order in some browser combinations), make the drop zone a <label> for that input so clicking and keyboard activation both work, announce progress and completion through an aria-live="polite" region rather than visual-only bars, and put file errors in the same place as the rest of your form error and focus handling. If you want to know whether your upload step is actually costing you submissions, instrument it the same way you’d instrument any other field and watch the drop-off rate.
For the submission itself, you don’t need a backend at all. Post the text fields plus the storage keys to a hosted endpoint like WebForms and route them to email, Slack or a webhook. The only custom code left in the stack is the signing function.
What this actually costs
Storage is cheap; egress is not. S3 charges roughly $0.09 per GB out to the internet, so a bucket of CVs that nobody downloads costs almost nothing, while a bucket of client video deliverables can produce an unpleasant invoice. Cloudflare R2 charges zero egress, which for any workload where files get read more than once makes the maths straightforward. We default new upload buckets to R2 for exactly that reason and only use S3 when the client is already deep in AWS and wants the event ecosystem.
Request costs are negligible until you do multipart with 5MB parts on large files, at which point a 1GB upload is 200 PUT requests plus the preflights. Still cents. The thing that actually bites is forgotten quarantine buckets with no lifecycle rule, quietly accumulating a terabyte of rejected uploads over three years.
Frequently Asked Questions
Can I do a direct-to-storage upload with no server at all?
No, not safely. Something has to hold the secret key and sign the request, and that something cannot be client-side JavaScript. The smallest version is a single edge function of about 40 lines, which you can run free on Cloudflare Workers, Netlify or Vercel. Any tutorial that embeds AWS credentials in the frontend is showing you how to get your bucket drained.
How do I stop someone reusing a presigned URL?
Keep the expiry short, 5 to 10 minutes is plenty since the clock is checked when the request starts, not when it ends. Scope each URL to exactly one object key so reuse just overwrites the same object. Then rate limit the signing endpoint, because that is the real control surface, not the URLs themselves.
What’s the maximum file size I should allow from a browser form?
For documents and images, 15MB to 25MB covers almost every legitimate case and keeps you on single PUT. Above about 100MB, switch to multipart so a dropped connection doesn’t restart from zero. Beyond 1GB you want a resumable protocol like tus or a specialist provider, because plain multipart with no resume across page reloads frustrates users badly.
Do I still need virus scanning if the files are never served publicly?
If a human on the client’s team will ever download and open them, yes. A CV bucket is a malware delivery channel aimed at an HR inbox. Running ClamAV in a Lambda container on the quarantine bucket costs a few dollars a month and removes the entire conversation from your next security review.
Does this work with a WordPress site instead of a static one?
It does, and it’s often worth doing. PHP’s uploadmaxfilesize and postmaxsize plus the host’s request timeout cause the same failures as serverless limits, especially on shared hosting. Sign the upload in a REST API route, send the bytes to storage directly, and store only the object key in postmeta. Your uploads directory stays small and backups stay fast.
If you’re adding uploads to an existing form this week, do these three things in order: move the bytes out of your request path, enforce the size limit in the signature rather than in JavaScript, and put a lifecycle rule on the quarantine bucket before you write any UI. Everything else, progress bars, drag-drop, image downscaling, is polish you can add on Thursday. The security boundary is the part you only get one chance to design.


