File uploads look like a solved problem, add an <input type="file">, POST it somewhere, done, right up until you’re storing user-controlled binaries on your infrastructure. Then it becomes one of the most security-sensitive features in your product: the place where an attacker gets to hand you arbitrary bytes and hope you mishandle them.
Here’s how to add uploads that are pleasant for users and don’t become the soft spot in your security posture.
Upload straight to storage, not through your server
The first architectural decision saves you the most pain: don’t route file bytes through your application server. Instead, use presigned URLs. Your server generates a short-lived, single-purpose URL that authorizes an upload directly to object storage (S3, R2, GCS); the browser uploads to that URL; your server records the metadata.
This means:
- Large files never tie up your app servers or hit request-size limits.
- Your compute cost doesn’t scale with upload bandwidth.
- The credential the browser gets is scoped to one object and expires in minutes.
The flow: client asks your API for an upload URL (specifying filename and content type) → API validates the request, generates a presigned PUT URL, records a pending upload row → client PUTs the file to storage → client tells your API the upload finished → API verifies and marks it complete.
A prompt to scaffold it:
Implement file uploads using presigned URLs to [S3/R2]. Flow: an API endpoint that validates and returns a presigned PUT URL plus a pending DB record, direct browser upload to storage, then a confirm endpoint. Enforce a max file size and an allowlist of content types at URL-generation time. Show me the validation and the presigned-URL generation before the client code.
Validate like you don’t trust anything, because you don’t
Every one of these is a real-world upload vulnerability. Cover them:
- Size limits, enforced server-side. Set the max at presigned-URL generation and in the storage bucket policy, not just in client JS a user can bypass.
- Content-type allowlist, not blocklist. Decide what you accept (“png, jpeg, pdf”) rather than what you reject. And don’t trust the client-supplied MIME type, verify by inspecting the file’s actual magic bytes after upload.
- Filename sanitization. Never use the user’s filename as a storage path. Generate your own key (a UUID) and store the original name as a display-only field. This kills path-traversal (
../../etc/...) outright. - Never serve uploads from your own domain. Serve user content from a separate domain or a storage CDN so a malicious HTML/SVG file can’t run scripts in your app’s origin. This is the single most-missed upload defense.
- Scan for malware if users share files with each other, before the file is downloadable by anyone else.
Make it feel good
Security handled, the UX details that matter:
- Progress. Presigned uploads expose real upload progress, wire it up. A determinate progress bar turns a nervous wait into a calm one.
- Drag-and-drop plus click. Support both; some users drag, some click.
- Validate before upload. Check size and type client-side too (as UX, not security) so users get instant feedback instead of waiting for a rejection.
- Resumability for large files, if your users deal in big assets. Multipart uploads let a dropped connection resume rather than restart.
Roll it out behind a flag, and mean it
Of all the features to gate behind a flag and a kill switch, an upload endpoint is near the top, because the failure modes are expensive and adversarial. A misconfigured content-type check, a bucket that’s more public than you thought, or a validation gap isn’t a cosmetic bug; it’s an incident.
Roll it out deliberately:
- Ship it behind a flag, on for your team only. Try to break your own validation: upload a
.phprenamed to.png, a 2GB file, a filename full of../. Confirm each is handled. - Enable for a small cohort and watch storage usage, error rates, and, critically, what content types are actually landing versus what you expected.
- Ramp up as it holds.
The flag is also your incident response plan. If a security report comes in, “an SVG upload can execute script when viewed”, you disable uploads in one click while you patch, rather than leaving the hole open through a deploy cycle. For a feature that accepts arbitrary bytes from strangers, an instant off-switch isn’t optional; it’s the difference between a contained scare and a bad week.
File uploads reward paranoia. Push bytes straight to storage, validate as if every file is hostile, serve user content off your origin, and keep a kill switch within reach.
ShipSilently gives security-sensitive features like uploads an instant, edge-evaluated kill switch, disable it everywhere in one click. Get started free.