Growth

How to Handle File Uploads in Your Product Without Getting Burned

October 30, 2026
13 min read

AI-friendly Markdown · structured for AI citations

How to Handle File Uploads in Your Product Without Getting Burned

How to handle file uploads safely: server-side checks, presigned URLs, private storage, safe download headers, image processing, upload UX and cleanup.

To handle file uploads in your product without getting burned, decide exactly what you accept (types, maximum size, how many files) from what users really upload, then enforce it on the server, not only in the browser. Check the file's actual content instead of trusting its extension or the browser's Content-Type, store it under a random key you generate, and send the bytes straight to object storage with a short-lived presigned URL. Keep the bucket private, hand out download links that expire in minutes, serve untrusted files with X-Content-Type-Options: nosniff and Content-Disposition: attachment, re-encode images in a background job (which also strips GPS data), and let lifecycle rules delete abandoned uploads.

This guide is for solo makers and tiny teams adding avatars, attachments, photos or documents to a live product. The worked example near the end is made up. Storage and security details come from the provider docs and OWASP, linked below and checked on 30 October 2026.

What should your product accept in the first place?

Set three limits for each place a user can attach a file:

  • Types. Name the formats the feature needs. An avatar needs JPEG and PNG. A receipts feature needs JPEG, PNG and PDF. The OWASP File Upload Cheat Sheet says to allow only the extensions the business function needs.

  • Maximum size. Look at real files. Ask five users for files they would attach, or log sizes for a week, and set the limit a little above normal use.

  • Count. Files per item and per user, so one account cannot fill your bucket.

Put these limits in one config that frontend and backend both read, so the form and the server never disagree.

Why isn't the accept attribute enough?

An accept="image/*" attribute only filters the file picker. MDN's page on the accept attribute notes that users can override it, so the requirement must be validated server-side.

On the server, check in layers. OWASP is clear that no single check is enough:

  1. Size first. Reject anything over the limit before reading it all into memory.

  2. Extension, after decoding the name. Compare the lowercased extension with your allowlist. OWASP lists the classic bypasses: double extensions like .jpg.php, null bytes, mixed case and alternative extensions a server might execute.

  3. Do not trust the declared type. The client's Content-Type is, in OWASP's words, trivial to spoof. Treat it as a hint.

  4. Check the content. Read the first bytes (the "magic bytes" or file signature) and confirm they match an allowed format. In JavaScript the file-type package does this, and its README is honest: detection is a best-effort hint, it does not prove the file is valid, and it does not cover text formats such as SVG or CSV.

  5. For images, decode and re-encode. OWASP recommends writing images back out in an allowed format with unneeded metadata removed. A file that will not decode as an image is not an image, whatever its first bytes claim.

Only users allowed to edit a record should attach to it; for shared workspaces, the post on adding team accounts to a product built for one user covers the permission checks.

Should uploads go through your server or straight to storage?

Proxying through your server is simple: the browser posts the file to your API, which validates it and writes it to storage. The cost is that every upload holds a request open on your app server for as long as the user's connection takes, uses memory or temp disk and hits request timeouts on many hosts. For small files on a quiet product, that is fine.

Direct-to-storage with presigned URLs keeps the bytes off your server:

  1. The browser asks your API: "I want to upload a 4 MB JPEG to this project."

  2. Your server checks the user, type and size, creates a database row with status pending and a random storage key, and signs a URL for exactly that key.

  3. The browser uploads directly to the bucket with that URL.

  4. The browser tells your API it is done. Your server checks the stored object and only then marks the row ready.

AWS's page on presigned URLs for Amazon S3 notes that a presigned URL can be reused until it expires and that uploading to an existing key replaces that object, so sign a fresh random key per upload. SDK-signed URLs can last up to 7 days; for uploads, minutes are plenty.

Cloudflare's R2 presigned URLs work the same way (GET, HEAD, PUT and DELETE, expiry from 1 second to 7 days), and Google Cloud Storage has signed URLs with the same 7-day maximum. All three docs say whoever holds the URL can use it, so treat it like a short-lived password. The bucket also needs a CORS rule for your app's origin.

How do you cap file size when the browser uploads directly?

A plain presigned PUT says "you may write this key", not "at most 10 MB". It depends on the provider:

  • Amazon S3. Browser POST uploads use a POST policy that can set a size range. The AWS example allows 1 to 10 MiB with ["content-length-range", 1048576, 10485760], and the policy can also pin the key prefix and Content-Type.

  • Google Cloud Storage. The x-goog-content-length-range header makes Cloud Storage reject a PUT outside the range with a 400. It is silently ignored on other methods.

  • Cloudflare R2. The R2 docs say POST form uploads are not currently supported. You can sign the Content-Type so other types fail, but for size, check the object after upload (a HEAD request returns its size) and delete it if it is too big.

Either way, never show or process a file just because the browser said it finished.

When do you need multipart or resumable uploads?

Only when files are big or connections are bad. A profile photo does not need it; video or users on patchy mobile data might.

  • S3 multipart upload. The file goes up in parts that can be retried one at a time. Per the S3 multipart limits, parts are 5 MiB to 5 GiB (the last can be smaller), up to 10,000 parts per upload.

  • The tus protocol. The tus resumable upload protocol is an open HTTP standard: the client asks how much the server has with HEAD, reads the Upload-Offset header, then sends the rest with PATCH. It suits products running their own upload endpoint.

Parts from multipart uploads that never finish are still stored; the costs section covers cleanup.

How should you name and store uploaded files?

Never use the user's filename as the storage path. OWASP's advice is to generate the name yourself, for example a UUID, because user-supplied names can carry path traversal, reserved names and overwrite tricks. Two users uploading photo.jpg must never collide.

  • Storage key: a random ID with no meaning, such as uploads/7f3c9a1e2b, with an extension from the detected type.

  • Database row: key, owner and workspace, original filename (display only, length-limited and cleaned), detected type, size, status (pending, ready, rejected) and created time.

  • Pending prefix: new uploads land under pending/ and the confirm step copies accepted files to their final key, so a lifecycle rule on that prefix clears whatever is left.

Keep the bucket private. AWS's Block Public Access page says new S3 buckets and objects do not allow public access by default. Leave it that way unless a file is truly meant to be public.

How do you serve user files without creating security holes?

Most upload holes show up at download time. Four habits cover most of them:

  • Short-lived signed download links. When a user opens an attachment, check they may see it, then return a signed GET URL valid for a few minutes. Sign a new one each time; never store them.

  • Set the type yourself. Serve the Content-Type you detected, plus X-Content-Type-Options: nosniff. MDN's nosniff reference explains that with nosniff, a file sent as text/plain is not interpreted as HTML even if it contains markup, which prevents XSS from user uploads.

  • Download, do not display, anything untrusted. Content-Disposition set to attachment tells the browser to save the file instead of rendering it. Use inline only for files you re-encoded yourself.

  • Serve from a different origin than your app, such as the bucket's own domain, so script inside a file cannot reach your app's session. If users share uploads publicly, the SideHunt guide to handling spam and abuse when your side project lets users post covers the user-content domain and abuse side.

Why are SVG and HTML uploads risky?

An SVG looks like an image but is an XML document that can contain script. PortSwigger's file upload vulnerabilities guide notes that uploaded HTML files or SVG images can carry script tags that become stored XSS when other users view them. For most products, simply refuse SVG and HTML. If you need SVG logos, convert them to PNG on the server.

Printed photos scattered across a wooden table, plants and interiors in soft light

What should happen to an image after it is uploaded?

Treat the original as raw material. In a background job after the confirm step:

  1. Re-encode it. OWASP notes this does not guarantee every payload is gone and that the image library itself handles untrusted input, so keep it updated and give it resource limits.

  2. Fix orientation, then strip metadata. Phone photos carry EXIF data, often including where the photo was taken. Leaking that is a privacy bug. The sharp output docs say all metadata is stripped by default, including the EXIF orientation, unless you call keepMetadata(). Call autoOrient() first, or photos come out sideways.

  3. Make the sizes you use. A thumbnail for lists, a medium size for pages. Generate WebP or AVIF if your frontend serves them.

  4. Derive variant keys from the original's ID, so deleting one upload finds every variant.

Run this in a job queue, not the request, and show a placeholder until the thumbnail is ready. For speed, serve the right size with width and height set.

What does a good upload experience look like?

  • Button plus drop zone. A normal button for keyboard and mobile users, drag and drop for desktop. State the limits next to it: "JPEG, PNG or PDF, up to 10 MB, 5 files max."

  • Check before sending. Check type and size the moment a file is chosen and reject it with a specific message before any upload starts. The MakerHunt post on writing error messages that keep users moving has the pattern for that copy.

  • Progress per file. XMLHttpRequest exposes an upload progress event for a real progress bar.

  • Retry and cancel. If one file fails, keep the others and offer retry for that one. Never clear the form because an attachment failed.

  • Paste (optional). For screenshot-heavy products, the paste event exposes pasted files. Same checks apply.

Several opened hard disk drives stacked on a white surface, circuit boards and connectors showing

What do uploads really cost to store and serve?

Object storage bills usually have three parts: data stored per month, requests, and data leaving the provider (egress). Prices change, so read your provider's official pricing page. Two examples, checked 30 October 2026: Cloudflare R2 pricing states there are no egress charges for any storage class, and Amazon S3 pricing lists data transfer out to the internet as billed, with a free monthly allowance shared across AWS services.

  • Delete abandoned uploads automatically. AWS's guide to deleting incomplete multipart uploads recommends an AbortIncompleteMultipartUpload lifecycle rule (its example uses DaysAfterInitiation of 7). R2's object lifecycle rules support the same action and deleting objects by age. Add an expiry rule on pending/ too.

  • Clean up orphans. A weekly job that compares one storage prefix with the database finds files with no row and rows with no file.

  • Count variants. Three sizes in two formats is six files per upload. Generate only what the frontend uses.

  • Per-user quotas. A visible storage limit per account protects you from the one user treating your product as a backup drive.

Do you need malware scanning?

Sometimes. It is worth it when other people download the files, such as shared documents in a B2B tool or files your team opens. It matters much less for images you re-encode. The common free option is ClamAV, an open source (GPLv2) anti-virus toolkit with a scanning daemon, clamd. Scan each confirmed upload in the background and mark it ready only when it passes. A signature scanner catches known malware, not everything, and OWASP lists it as one layer among many.

What happens when a user deletes a file or their account?

Deletion must reach storage, not just the row. Delete the original and every variant, then the row; for an account, queue a job that does this for all its files. Two catches: a signed URL already handed out works until it expires (keep them short), and a CDN in front of public files may keep a cached copy until purged.

A made-up example: Snapjoist fixes its photo uploads

This example is invented. Snapjoist is a fictional photo log for small renovation crews: workers take before and after photos on site, and the client sees them on a shared project page. Its fictional founder, Isembard, built uploads in an afternoon: the app posted full-size photos to his API, which saved them to a public bucket under their original filenames.

In the story, three problems showed up in month one: uploads over weak site connections timed out, two photos with the same camera filename overwrote each other, and a client noticed anyone with a link could see the photos, GPS location still embedded.

Isembard's one-week fix:

  • Limits from real use: JPEG and PNG from the app, a size cap just above the crews' biggest photos, 30 photos per site visit.

  • Presigned direct uploads with random keys under pending/ and a confirm call that checks size and content.

  • A background job that auto-orients, strips metadata and writes a thumbnail and a medium WebP. Originals stay private.

  • A private bucket, project permission checks, and signed links that expire in minutes.

  • Lifecycle rules for unconfirmed and incomplete uploads, plus a weekly orphan check.

Nothing in the fix was clever. It was the boring version he should have started with.

What upload mistakes burn small products?

Weak

Better

Rely on the accept attribute

Server allowlist: extension, detected content, re-encode images

Trust the browser's Content-Type

Detect the type from the bytes and store your own value

Save files under the user's filename

Random key; original name kept for display only

Stream every upload through the app server

Presigned URL to storage, then a server confirm step

Presigned PUT with no size check

POST policy range on S3, length-range header on GCS, or a HEAD check after upload

Public bucket, permanent links

Private bucket, permission check, links that expire in minutes

Accept SVG and HTML and show them inline

Refuse them or convert SVG to PNG; untrusted files as attachments with nosniff

Show phone photos as uploaded

Auto-orient, strip EXIF and GPS, resize in a background job

Never delete anything

Lifecycle rules, orphan cleanup, deletion that reaches storage

A 7-day plan to fix file uploads

A planning guess for one maker improving an existing upload feature part-time.

Day

Do this

Done when

1

List every upload point; set types, max size and count from real files

One shared limits config for frontend and backend

2

Server checks: size, extension allowlist, content detection, image re-encode

A renamed non-image and an oversized file are both rejected

3

Random storage keys and a database row per upload with a status

No storage path contains a user-supplied name

4

Presigned uploads with a confirm step and a size cap for your provider

Uploads no longer pass through the app server

5

Private bucket; short-lived signed links with nosniff and attachment headers

Only allowed users can open files

6

Image job (orient, strip, thumbnails); UX: limits text, early errors, progress, retry

A phone photo shows upright with no GPS data

7

Lifecycle rules, orphan check, deletion path for files and accounts

Deleting a test account removes all its objects

File upload FAQ

Can I store uploaded files in my database?

You can, but OWASP warns about performance and backup size and advises it mainly when someone on the team knows databases well. Object storage plus a row per file is simpler.

Should I let users upload SVG logos?

Only if you convert them to PNG on the server or serve them as downloads from a separate origin. Shown inline on your app's domain, an SVG can run script.

Do I need resumable uploads for profile pictures?

No. A single presigned upload with a size cap is fine for small files. Use multipart or tus when files are large or connections unreliable.

Uploads should be boring

Good file uploads are invisible: the file goes up, shows the right way round, and nobody else can see it. And when the product around those uploads is ready for more makers to try, MakerHunt is a friendly place to launch it.

file uploadsfile upload securitypresigned URLsimage uploadsS3 presigned URLCloudflare R2upload UXOWASPmakersGrowth