HomeProjectsBlogContact

File Uploads Done Right: Security, Storage and Scale

A file upload form is one of the riskiest features you can build. How to validate real file types, store files safely, upload straight to object storage, and serve user content without opening holes.

File Uploads Done Right: Security, Storage and Scale

"Let users upload a profile picture" sounds like a twenty-minute task. It's actually one of the riskiest features in a web app. An upload form accepts arbitrary bytes from strangers and stores them on your infrastructure. Get it wrong and you're hosting malware, serving cross-site scripting, or paying for someone else's 50GB of files.

Here's how to do file uploads safely, and in a way that scales.

Validate on the server, always

Client-side checks (accept="image/*", file size checks in JavaScript) are for user experience only. Anyone can bypass them with curl. Every rule must be enforced on the server.

Check the real file type

The filename extension is just text the user controls, and the Content-Type header is set by the client. Neither proves anything.

Instead, check the file's magic bytes, the signature at the start of the file:

  • PNG starts with 89 50 4E 47
  • JPEG starts with FF D8 FF
  • PDF starts with 25 50 44 46 (%PDF)

Libraries such as file-type (Node.js) or python-magic do this for you. Use an allow-list of types you accept, never a block-list of types you reject.

Enforce size limits at every layer

  • In your reverse proxy or platform (e.g. Nginx client_max_body_size)
  • In your upload middleware (multer, busboy, your framework's settings)
  • Per file type: a 2MB limit for avatars, a larger one for documents

Without limits, a single request can fill your disk or exhaust memory.

Never trust the filename

User-supplied filenames can contain path traversal (../../etc/passwd), special characters, extremely long names or names that collide with existing files.

  • Generate your own name, such as a UUID: a3f1c9e2-....png.
  • Store the original filename separately in the database if you want to display it.
  • Never use user input to build a filesystem path.

Store files outside your application

Don't save uploads into a folder your web server executes or serves as code. If someone uploads shell.php into a directory that runs PHP, they now control your server.

Better: use object storage, such as Amazon S3, Cloudflare R2, Google Cloud Storage, Backblaze B2, or Supabase Storage. Benefits:

  • Files never touch an executable directory.
  • Storage scales without disk management.
  • Your app servers stay stateless, so you can scale or redeploy freely.
  • Serving through a CDN is straightforward.

Upload directly to storage with presigned URLs

Routing large files through your API server wastes bandwidth and memory, and serverless functions often have small request body limits. Instead:

  1. The client asks your API: "I want to upload photo.png, 1.2MB."
  2. Your API checks permissions, size and type, then generates a presigned URL, a short-lived URL that allows uploading exactly one object.
  3. The client uploads the file directly to storage using that URL.
  4. The client tells your API it's done; the API verifies the object exists and records it in the database.

Your server handles a few small JSON requests while the storage service handles the heavy lifting. Most object storage providers support this pattern.

Because the file bypasses your server, verify it after upload: check the object's real size and type, and process or scan it asynchronously before marking it as approved.

Process images before using them

For image uploads, re-encode them with a library like sharp (Node) or Pillow (Python):

  • Resize to the sizes you actually display.
  • Convert to efficient formats like WebP or AVIF.
  • Strip metadata. Photos often contain EXIF data, including GPS coordinates of where they were taken. Removing it protects user privacy.
  • Re-encoding also neutralises many malformed or malicious image payloads.

Be careful with SVG and HTML

SVG files can contain JavaScript. If you serve a user-uploaded SVG from your main domain and someone opens it directly, that script runs with your site's origin. That's stored XSS.

Options:

  • Don't accept SVG from untrusted users.
  • Sanitize SVGs with a dedicated sanitizer.
  • Serve user content from a separate domain (like usercontent.yoursite.net) so scripts can't touch your main site's cookies or storage.

The same goes for HTML files and anything else a browser might render.

Serve files safely

  • Set the correct Content-Type and add X-Content-Type-Options: nosniff so browsers don't guess.
  • Use Content-Disposition: attachment for files meant to be downloaded rather than viewed.
  • For private files, keep the bucket private and generate short-lived signed download URLs after checking permissions.
  • Put a CDN in front of public files.

Scan when the risk justifies it

If users share files with other users (documents, attachments), scan uploads for malware, with ClamAV or your cloud provider's scanning service, before making them available. Run scanning asynchronously and keep files in a "pending" state until they pass.

Control abuse and cost

  • Rate limit upload endpoints per user.
  • Set per-user storage quotas.
  • Clean up orphaned files: uploads that started but were never attached to a record.
  • Add lifecycle rules in your storage provider to delete temporary uploads automatically.

Checklist

  • Server-side validation of type (magic bytes) and size
  • Allow-list of accepted file types
  • Generated filenames; originals stored only as metadata
  • Files in object storage, never in an executable directory
  • Presigned URLs for direct uploads
  • Images resized, re-encoded and stripped of metadata
  • SVG/HTML blocked, sanitized or served from a separate domain
  • Correct Content-Type, nosniff and signed URLs for private files
  • Rate limits, quotas and cleanup jobs

For the rest of your app's security posture, work through the web application security checklist.

FAQ

How do I validate file type securely?

Check the file's magic bytes on the server against an allow-list. Don't rely on the extension or the Content-Type header, since both are controlled by the client.

Should I store uploaded files in the database?

Usually not. Store files in object storage and keep metadata (owner, path, size, type) in the database. It's cheaper, faster and easier to serve through a CDN.

What is a presigned URL?

A temporary URL generated by your server that lets a client upload or download one specific object directly to or from storage, without exposing your storage credentials.

Keep reading

More from the workbench

All articles →