How to Add File Uploads That Don't Go Through Your Server

    6 min read3 approaches compared

    The instinct is to POST the file to your API and have it forward the bytes to storage. That works until someone uploads a 500MB video, at which point you're paying for the transfer twice, holding a request open for minutes, and hitting a platform body-size limit you didn't know existed.

    The better pattern — presigned URLs, where the browser uploads straight to object storage — is barely more code and removes your server from the data path entirely. This guide covers that, plus the validation that actually matters, because uploads are one of the more reliable ways to introduce a security hole.

    Your server should authorize the upload, not carry it

    The server's job is deciding whether this user may upload this thing and recording that it happened. The bytes should go directly from the browser to storage.

    • Under ~4MB and rare (avatars): proxying through your server is fine and simpler.
    • Anything larger, or frequent: presigned URLs direct to S3, R2, or Supabase Storage.
    • Over ~100MB: multipart upload, so a dropped connection resumes rather than restarting.
    • Never trust the filename or the client-supplied content type. Both are attacker-controlled.
    • Serve user uploads from a different origin than your app, or a stored HTML file becomes stored XSS on your domain.

    The approaches

    Presigned URLs (S3, R2, Supabase Storage, GCS)

    // best for: Almost every upload beyond a small avatar.

    • AWS
    • Cloudflare
    • Supabase
    • Google Cloud
    • Firebase
    • Appwrite

    Your API receives a request to upload, checks the user is allowed, and returns a short-lived signed URL. The browser PUTs the file straight to storage. Your server never sees the bytes, so there's no request timeout, no memory pressure, and no egress charge for the round trip.

    The constraints go into the signature. You can pin the content type, cap the size, and set an expiry measured in minutes — so a leaked URL is useless quickly and can't be used to upload something other than what was authorized.

    The piece people miss is confirmation. Because the upload bypasses your server, you don't automatically know it succeeded. Either have the client call back to confirm, or subscribe to storage events. Without one of these you accumulate orphaned objects and database rows pointing at files that were never finished.

    typescript
    // Constraints live in the signature, so a leaked URL can't be
    // used to upload something other than what you authorized.
    const url = await getSignedUrl(s3, new PutObjectCommand({
      Bucket: "uploads",
      Key: `u/${userId}/${crypto.randomUUID()}`,  // never the user's filename
      ContentType: "image/jpeg",
      ContentLength: size,
    }), { expiresIn: 300 });
    
    // The upload bypasses your server, so nothing tells you it
    // finished. Confirm explicitly or reconcile via storage events,
    // or you accumulate rows pointing at files that never landed.

    The gotcha

    CORS on the bucket. The browser PUTs cross-origin, so without a CORS rule allowing PUT from your origin — and exposing `ETag` for multipart — every upload fails with an opaque network error that looks like a bad signature. It's the first thing to check when presigned uploads fail.

    Proxying through your server

    // best for: Small files where you must inspect content before storing it.

    • Express.js
    • Django
    • Laravel
    • NestJS
    • FastAPI
    • Ruby on Rails

    Multer, Django's file handling, and Laravel's request files all accept multipart uploads and hand you the bytes. For a profile picture this is perfectly reasonable and avoids the confirmation dance.

    The reason to choose it deliberately is inspection: virus scanning, content moderation, or stripping EXIF data before anything is stored. If the file must be examined before it exists in storage, it has to pass through something you control.

    Stream rather than buffer. Reading a whole file into memory means concurrent large uploads exhaust the process. Every framework's file handling can stream to a temp file or straight through to storage; the in-memory mode is usually the default and usually wrong for anything but tiny files.

    Note that many serverless platforms cap request bodies at a few megabytes, which rules this out entirely there.

    The gotcha

    EXIF data in photos includes GPS coordinates. Serving user-uploaded images unmodified publishes the location where each was taken — a genuine privacy incident on anything user-facing. Strip metadata during processing.

    Managed upload components (Uploadthing, Filestack, Cloudinary, no-code)

    // best for: Teams who want resumable uploads, transforms, and a CDN without building them.

    • Vercel
    • Cloudflare
    • Bubble
    • Webflow
    • Retool
    • Airtable
    • Softr

    These bundle the whole pipeline: a client component with progress and retry, signed uploads, virus scanning, and image transformation on delivery. Cloudinary in particular turns responsive images into a URL parameter rather than a build step.

    The economics favor them when image processing is the requirement. Generating and storing five sizes of every upload yourself is a real pipeline; a transformation URL is not.

    In no-code platforms this is usually the only option, and it's fine — Bubble and Webflow have native file fields backed by their own storage. The limitation is that access control tends to be coarse, so genuinely private files may need a different approach.

    The gotcha

    Default configurations frequently make uploads publicly readable by anyone with the URL, and those URLs are often guessable or sequential. For anything private, verify the default access level rather than assuming — this is the most common way private documents end up publicly reachable.

    Common pitfalls

    Trusting the content type header

    `Content-Type` is set by the client and means nothing. A PHP script can claim to be `image/png`. Validate by inspecting magic bytes server-side, and never derive the stored file's type from what was sent.

    Using the user's filename as the storage key

    Filenames carry path traversal (`../../etc/passwd`), null bytes, and unicode tricks, and they collide. Generate a UUID for the key and keep the original name as display metadata only.

    Serving uploads from your application's origin

    An uploaded HTML or SVG file served from your domain executes with your origin's cookies — stored XSS with full session access. Serve user content from a separate domain, and set `Content-Disposition: attachment` for anything not meant to render inline.

    No size limit before the upload starts

    Checking size after receiving the file means you already paid for the bandwidth. Enforce it in the presigned policy so storage rejects oversized uploads, and check `file.size` client-side for a fast error message.

    Questions people actually ask

    Should uploads go through my server?

    Only for small files, or when you must inspect content before storing. Otherwise presigned URLs direct to storage — no timeouts, no memory pressure, no double bandwidth, and no platform body-size limit.

    How do I validate file types safely?

    Read the magic bytes server-side rather than trusting the extension or the content type header. Maintain an allowlist of accepted types, never a blocklist of rejected ones.

    How do I handle very large files?

    Multipart upload, which splits the file into chunks uploaded independently and reassembled by the storage provider. A dropped connection then resumes from the last completed chunk instead of restarting a 2GB transfer.

    Need someone who's done this before?

    Browse vetted developers who work with these tools day to day.

    Related guides