File Upload Checklist
The checklist
Validation
- File size limit enforced (streaming, not after-the-fact)
- MIME type validated via magic bytes (not just the declared Content-Type)
- Allowed file types are whitelisted (not blacklisted)
- Filename sanitized (UUID on disk, original name in database only)
- Number of files per request limited (busboy
limits.files) - Content-Length header checked as a first defense (but not trusted alone)
Security
- Path traversal prevented (no user input in file paths)
- File content not executed (no dynamic includes of uploaded files)
- SVG files treated as dangerous (can contain JavaScript)
- Uploaded files served from a different domain or with
Content-Disposition: attachment - Access control checked before serving private files
- Signed URLs used for temporary access without cookies
Storage
- Files stored outside the web root (not in
public/) - Unique filenames prevent collisions (UUID-based)
- Database records metadata (original name, stored name, MIME, size, owner)
- Storage layer abstracted behind an interface (swap local for S3)
- Partial files cleaned up on failed or cancelled uploads
- Orphaned files cleaned up periodically (database record exists but file missing, or vice versa)
Serving
- Content-Type header set correctly from stored MIME type
- Content-Length header set from file size
- Content-Disposition set appropriately (inline for images, attachment for downloads)
- Range requests supported for large files and media
- Caching headers set for immutable files (thumbnails, static assets)
Production
- Streaming uploads — files are not loaded entirely into memory
- Image thumbnails generated on upload (not on every request)
- Upload progress trackable by the client
- Presigned URLs available for direct-to-cloud uploads
- File size and count quotas per user
Common mistakes
Trusting the filename. Using the uploaded filename as the storage path enables path traversal. Always generate a safe, unique name.
Trusting Content-Type. A renamed .exe can have Content-Type: image/jpeg. Check magic bytes.
Loading files into memory. Works in development with small files. Crashes in production with large files and concurrent uploads.
Serving uploaded files from the same origin. If an uploaded HTML file is served from your domain, it runs JavaScript in your origin’s context (XSS). Serve uploads from a different domain or with attachment disposition.
No access control on the download URL. If /uploads/abc123.jpg is publicly accessible and the file ID is guessable, anyone can download any file. Use auth checks or signed URLs.
No cleanup on failed uploads. A cancelled upload leaves a partial file on disk. A deleted database record leaves an orphaned file. Run cleanup periodically.
Exercises
Exercise 1: Go through the checklist for your file sharing app. How many items pass?
Exercise 2: Try uploading an HTML file and serving it with Content-Disposition: inline. Does it execute JavaScript? (It should not if you serve from a different origin or set proper CSP.)
Exercise 3: Check for orphaned files: files on disk with no database record, and database records with no file on disk.
Why should uploaded files be served from a different domain than your application?