$ cat ~/field-notes/file-uploads-done-right.md
File Uploads Done Right
I've seen file upload implementations that stored files in the database as BLOBs, routed 500MB video files through Node.js servers, trusted the Content-Type header from the client, and named stored files with the original filename from the user's machine.
Every one of those is a mistake. Let's do it correctly.
The Wrong Approach
// Server-side upload handler — DON'T DO THIS
app.post('/upload', upload.single('file'), async (req, res) => {
const file = req.file;
// Wrong: trusting client-provided filename
const filename = file.originalname;
// Wrong: trusting client-provided content type
const type = file.mimetype;
// Wrong: storing on local disk (no redundancy, fills up, not scalable)
// Wrong: using user-provided filename (path traversal attack)
fs.writeFileSync(`/uploads/${filename}`, file.buffer);
// Wrong: storing in database (bloats DB, slow queries)
await db.query('INSERT INTO files (data) VALUES ($1)', [file.buffer]);
res.json({ url: `/uploads/${filename}` });
});
Problems: server handles all file I/O (slow, ties up connections), filename is attacker-controlled, type is attacker-controlled, disk fills up, doesn't scale horizontally.
The Right Architecture: Presigned URLs
Route files directly from the browser to object storage. Your server only issues upload permissions.
BAD: Browser → Your Server → S3
GOOD: Browser → S3 directly (your server only issues the presigned URL)
// Server: issue an upload permission
app.post('/upload/presign', requireAuth, async (req, res) => {
const { filename, contentType, contentLength } = req.body;
// Validate before issuing permission
const allowed = validateUploadRequest(filename, contentType, contentLength);
if (!allowed.ok) {
return res.status(400).json({ error: allowed.error });
}
// Generate a random key — never use the user's filename
const key = `uploads/${req.user.id}/${crypto.randomUUID()}/${sanitizeFilename(filename)}`;
const { url, fields } = await s3.createPresignedPost({
Bucket: process.env.S3_BUCKET,
Fields: {
key,
'Content-Type': contentType,
},
Conditions: [
['content-length-range', 0, 10 * 1024 * 1024], // 10MB max
['eq', '$Content-Type', contentType],
],
Expires: 300, // URL valid for 5 minutes
});
// Record pending upload
const uploadId = await db.createPendingUpload({
userId: req.user.id,
key,
filename: sanitizeFilename(filename),
status: 'pending',
});
res.json({ uploadId, url, fields });
});
// Browser: upload directly to S3
async function uploadFile(file) {
// Step 1: Get presigned URL from your server
const { uploadId, url, fields } = await fetch('/upload/presign', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
filename: file.name,
contentType: file.type,
contentLength: file.size,
}),
}).then(r => r.json());
// Step 2: Upload directly to S3 (no traffic through your server)
const formData = new FormData();
Object.entries(fields).forEach(([k, v]) => formData.append(k, v));
formData.append('file', file); // Must be last
const uploadResponse = await fetch(url, {
method: 'POST',
body: formData,
});
if (!uploadResponse.ok) {
throw new Error('Upload failed');
}
// Step 3: Confirm with your server
await fetch(`/upload/${uploadId}/confirm`, { method: 'POST' });
}
Validate File Type by Magic Bytes
File extensions and Content-Type headers are both user-controlled. Validate by reading the actual file contents.
// Magic byte signatures for common types
const SIGNATURES = {
'image/jpeg': [[0xFF, 0xD8, 0xFF]],
'image/png': [[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]],
'image/gif': [[0x47, 0x49, 0x46, 0x38, 0x37, 0x61], // GIF87a
[0x47, 0x49, 0x46, 0x38, 0x39, 0x61]], // GIF89a
'image/webp': [[0x52, 0x49, 0x46, 0x46]], // RIFF (need to also check bytes 8-12)
'application/pdf': [[0x25, 0x50, 0x44, 0x46]],
};
async function detectFileType(buffer) {
const bytes = new Uint8Array(buffer.slice(0, 16));
for (const [mimeType, signatures] of Object.entries(SIGNATURES)) {
for (const sig of signatures) {
if (sig.every((byte, i) => bytes[i] === byte)) {
return mimeType;
}
}
}
return null;
}
// Validate after S3 upload via S3 event → Lambda → your API
async function validateUpload(s3Key) {
const object = await s3.getObject({
Bucket: process.env.S3_BUCKET,
Key: s3Key,
Range: 'bytes=0-15', // Only read the header bytes
});
const buffer = await object.Body.transformToByteArray();
const detectedType = await detectFileType(buffer.buffer);
if (!ALLOWED_TYPES.includes(detectedType)) {
// Delete the file and reject
await s3.deleteObject({ Bucket: process.env.S3_BUCKET, Key: s3Key });
throw new Error(`Rejected: detected type ${detectedType}`);
}
return detectedType;
}
Use the file-type npm package if you don't want to maintain magic bytes manually — it covers hundreds of formats.
Enforce Limits at the Right Layer
# nginx.conf — enforce limits before Node sees the request
client_max_body_size 10m;
client_body_timeout 30s;
// Express — secondary check
app.use(express.json({ limit: '1mb' }));
// multer — for multipart uploads going through your server
const upload = multer({
limits: {
fileSize: 10 * 1024 * 1024, // 10MB
files: 5, // Max files per request
fields: 10, // Max non-file fields
},
});
For presigned uploads: enforce in the presigned URL conditions (as shown above). S3 rejects uploads that exceed the limit before they're stored.
Generate Safe Keys
function sanitizeFilename(original) {
// Strip path components (defense against ../../../etc/passwd)
const basename = path.basename(original);
// Keep only safe characters
return basename
.replace(/[^a-zA-Z0-9._-]/g, '_')
.replace(/_{2,}/g, '_')
.slice(0, 200); // Max length
}
function generateStorageKey(userId, originalFilename) {
const sanitized = sanitizeFilename(originalFilename);
const uuid = crypto.randomUUID();
const date = new Date().toISOString().slice(0, 10); // YYYY-MM-DD
// Structure: user partitioned, date organized, UUID for uniqueness
return `uploads/${userId}/${date}/${uuid}/${sanitized}`;
}
Never use the original filename as the storage key. User-provided filenames contain path traversal attempts, null bytes, special characters, and filenames like ../../.env.
Process Uploads Asynchronously
Don't make the user wait for virus scanning, image resizing, or metadata extraction:
// After confirming upload:
app.post('/upload/:id/confirm', requireAuth, async (req, res) => {
const upload = await db.getPendingUpload(req.params.id, req.user.id);
if (!upload) return res.status(404).json({ error: 'Not found' });
// Mark as processing and return immediately
await db.updateUpload(upload.id, { status: 'processing' });
// Queue background jobs
await queue.add('validate-upload', { uploadId: upload.id, key: upload.key });
await queue.add('generate-thumbnails', { uploadId: upload.id });
res.json({ status: 'processing', uploadId: upload.id });
});
// Poll for status
app.get('/upload/:id/status', requireAuth, async (req, res) => {
const upload = await db.getUpload(req.params.id, req.user.id);
res.json({ status: upload.status, url: upload.publicUrl });
});
Serving Files Securely
Never serve user uploads from the same domain as your app — XSS from uploaded HTML/SVG files would have access to your cookies.
// Serve from a separate domain or use pre-signed read URLs
app.get('/files/:id', requireAuth, async (req, res) => {
const file = await db.getFile(req.params.id);
// Check user has permission to this file
if (!canAccess(req.user, file)) {
return res.status(403).json({ error: 'Forbidden' });
}
// Generate a short-lived presigned GET URL
const url = await s3.getSignedUrl('getObject', {
Bucket: process.env.S3_BUCKET,
Key: file.key,
Expires: 3600, // 1 hour
ResponseContentDisposition: `attachment; filename="${file.filename}"`,
});
// Redirect to S3 (client fetches directly)
res.redirect(url);
});
The Bottom Line
File uploads touch security, performance, and reliability simultaneously. The naive implementation gets all three wrong.
The rules:
- Presigned URLs: browsers upload directly to S3/R2, not through your server
- Validate by magic bytes, not extension or Content-Type header
- Generate storage keys — never use user-provided filenames
- Enforce size limits at nginx/load balancer, not just application code
- Serve uploads from a separate domain or with presigned read URLs
- Process async: validate, resize, scan in background jobs
- Set expiry on presigned upload URLs (5 minutes is plenty)
Most of the complexity here is one-time setup. Once the pattern is in place, adding new upload types is straightforward.