File Uploads Done Right: S3, Presigned URLs, and Validation That Actually Works
Why your server should never touch the file bytes ā and the two upload patterns that cover every use case
Senior Developer

Most file upload tutorials show you how to receive a file on your server and then push it to S3. That approach works, but it has a problem: every uploaded file passes through your server, consuming memory, bandwidth, and time. Upload a 50MB video and your Node.js process is tied up streaming bytes it does not need to see.
The production pattern is different. Your server generates a presigned URL ā a temporary, pre-authorized S3 link ā and sends it to the client. The client uploads directly to S3. Your server never touches the file bytes. This scales to any file size without touching your infrastructure.
This guide covers both approaches: direct server-side uploads for simple cases, and presigned URL uploads for everything else.
Setup
npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner multer sharp// src/lib/s3.js
import { S3Client } from '@aws-sdk/client-s3';
export const s3 = new S3Client({
region: process.env.AWS_REGION || 'us-east-1',
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
},
});
export const BUCKET = process.env.S3_BUCKET;Approach 1 ā Server-Side Upload (Simple Files, Under 10MB)
Use this for small files like avatars or documents where you want to process the file before storing it (resize, validate, virus scan).
// src/routes/upload.js
import multer from 'multer';
import sharp from 'sharp';
import { PutObjectCommand } from '@aws-sdk/client-s3';
import { s3, BUCKET } from '../lib/s3.js';
import { randomUUID } from 'crypto';
import path from 'path';
// Store files in memory ā never on disk
const upload = multer({
storage: multer.memoryStorage(),
limits: {
fileSize: 5 * 1024 * 1024, // 5MB hard limit
files: 1, // One file per request
},
fileFilter: (req, file, cb) => {
// Whitelist allowed MIME types ā never trust the extension alone
const allowed = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
if (allowed.includes(file.mimetype)) {
cb(null, true);
} else {
cb(new Error(`File type ${file.mimetype} not allowed`), false);
}
},
});
router.post('/upload/avatar',
authenticate,
upload.single('avatar'),
async (req, res) => {
if (!req.file) {
return res.status(400).json({ error: 'No file provided' });
}
try {
// Process the image ā resize and convert to WebP
// This also strips EXIF data (GPS location, device info, etc.)
const processed = await sharp(req.file.buffer)
.resize(400, 400, {
fit: 'cover',
position: 'centre',
})
.webp({ quality: 85 })
.toBuffer();
// Generate a non-guessable key ā never use the original filename
const key = `avatars/${req.user.id}/${randomUUID()}.webp`;
await s3.send(new PutObjectCommand({
Bucket: BUCKET,
Key: key,
Body: processed,
ContentType: 'image/webp',
// Cache for 1 year ā content-addressed by UUID so cache is always valid
CacheControl: 'public, max-age=31536000, immutable',
// Prevent the browser from sniffing content type
Metadata: {
uploadedBy: req.user.id,
originalName: req.file.originalname.slice(0, 100),
},
}));
const url = `https://${BUCKET}.s3.${process.env.AWS_REGION}.amazonaws.com/${key}`;
// Store reference in database
await db.query(
'UPDATE users SET avatar_url = $1 WHERE id = $2',
[url, req.user.id]
);
res.json({ url, key });
} catch (err) {
req.log?.error({ error: err.message }, 'Avatar upload failed');
res.status(500).json({ error: 'Upload failed' });
}
}
);Important: Never use the original filename as the S3 key. User-supplied filenames can contain path traversal characters (../../etc/passwd), extremely long strings, or overwrite existing files. Always generate your own key with a UUID.
Approach 2 ā Presigned URL Upload (Large Files, Direct to S3)
For anything above 10MB ā videos, large PDFs, raw files ā route the upload directly from the client to S3. Your server only generates a temporary signed URL.
// src/routes/upload.js
import { PutObjectCommand, GetObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { s3, BUCKET } from '../lib/s3.js';
import { randomUUID } from 'crypto';
// Step 1 ā Client requests a presigned upload URL
router.post('/upload/presign', authenticate, async (req, res) => {
const { filename, contentType, fileSize } = req.body;
// Validate on the server before generating the URL
const ALLOWED_TYPES = {
'image/jpeg': '.jpg',
'image/png': '.png',
'image/webp': '.webp',
'video/mp4': '.mp4',
'application/pdf': '.pdf',
};
if (!ALLOWED_TYPES[contentType]) {
return res.status(400).json({ error: 'File type not allowed' });
}
const MAX_SIZE = 500 * 1024 * 1024; // 500MB
if (fileSize > MAX_SIZE) {
return res.status(400).json({ error: 'File too large' });
}
const ext = ALLOWED_TYPES[contentType];
const key = `uploads/${req.user.id}/${randomUUID()}${ext}`;
// Generate a presigned URL valid for 15 minutes
const command = new PutObjectCommand({
Bucket: BUCKET,
Key: key,
ContentType: contentType,
ContentLength: fileSize, // S3 enforces this ā prevents size switching
Metadata: {
userId: req.user.id,
originalFilename: filename?.slice(0, 100) || 'unknown',
},
});
const uploadUrl = await getSignedUrl(s3, command, {
expiresIn: 15 * 60, // 15 minutes
});
// Store a pending record ā confirmed after client signals completion
const { rows } = await db.query(`
INSERT INTO uploads (user_id, s3_key, content_type, file_size, status)
VALUES ($1, $2, $3, $4, 'pending')
RETURNING id
`, [req.user.id, key, contentType, fileSize]);
res.json({
uploadUrl,
uploadId: rows[0].id,
key,
expiresIn: 15 * 60,
});
});
// Step 2 ā Client calls this after the upload completes
router.post('/upload/confirm/:uploadId', authenticate, async (req, res) => {
const { uploadId } = req.params;
const upload = await db.query(
'SELECT * FROM uploads WHERE id = $1 AND user_id = $2 AND status = $3',
[uploadId, req.user.id, 'pending']
);
if (!upload.rows[0]) {
return res.status(404).json({ error: 'Upload not found' });
}
// Verify the file actually made it to S3
try {
await s3.send(new GetObjectCommand({
Bucket: BUCKET,
Key: upload.rows[0].s3_key,
}));
} catch (err) {
return res.status(400).json({ error: 'File not found in storage ā upload may have failed' });
}
await db.query(
'UPDATE uploads SET status = $1, confirmed_at = NOW() WHERE id = $2',
['confirmed', uploadId]
);
const fileUrl = `https://${BUCKET}.s3.${process.env.AWS_REGION}.amazonaws.com/${upload.rows[0].s3_key}`;
res.json({ url: fileUrl, key: upload.rows[0].s3_key });
});On the frontend:
// Client-side upload flow
async function uploadFile(file) {
// Step 1: Get presigned URL
const { uploadUrl, uploadId } = await fetch('/upload/presign', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
filename: file.name,
contentType: file.type,
fileSize: file.size,
}),
}).then(r => r.json());
// Step 2: Upload directly to S3 (no auth header ā URL is already signed)
const uploadResponse = await fetch(uploadUrl, {
method: 'PUT',
body: file,
headers: {
'Content-Type': file.type,
},
});
if (!uploadResponse.ok) {
throw new Error('Upload to S3 failed');
}
// Step 3: Confirm with your server
const { url } = await fetch(`/upload/confirm/${uploadId}`, {
method: 'POST',
}).then(r => r.json());
return url;
}Generating Presigned Download URLs (Private Files)
If your files should not be publicly accessible ā user documents, invoices, private media ā do not set a public S3 policy. Generate presigned download URLs on demand.
router.get('/files/:uploadId/download', authenticate, async (req, res) => {
const upload = await db.query(
'SELECT * FROM uploads WHERE id = $1 AND user_id = $2 AND status = $3',
[req.params.uploadId, req.user.id, 'confirmed']
);
if (!upload.rows[0]) {
return res.status(404).json({ error: 'File not found' });
}
const command = new GetObjectCommand({
Bucket: BUCKET,
Key: upload.rows[0].s3_key,
ResponseContentDisposition: `attachment; filename="${upload.rows[0].original_filename}"`,
});
// URL valid for 5 minutes ā short-lived for private files
const downloadUrl = await getSignedUrl(s3, command, { expiresIn: 300 });
res.json({ downloadUrl, expiresIn: 300 });
});Deleting Files
Always delete from both S3 and your database. Orphaned S3 objects cost money and are a data retention liability.
router.delete('/files/:uploadId', authenticate, async (req, res) => {
const upload = await db.query(
'SELECT * FROM uploads WHERE id = $1 AND user_id = $2',
[req.params.uploadId, req.user.id]
);
if (!upload.rows[0]) {
return res.status(404).json({ error: 'File not found' });
}
// Delete from S3 first
await s3.send(new DeleteObjectCommand({
Bucket: BUCKET,
Key: upload.rows[0].s3_key,
}));
// Then delete the DB record
await db.query('DELETE FROM uploads WHERE id = $1', [req.params.uploadId]);
res.json({ deleted: true });
});S3 Bucket Policy (Least Privilege)
Your application's IAM user should only have the permissions it actually needs ā never S3FullAccess.
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"s3:PutObject",
"s3:GetObject",
"s3:DeleteObject"
],
"Resource": "arn:aws:s3:::your-bucket-name/*"
},
{
"Effect": "Allow",
"Action": "s3:ListBucket",
"Resource": "arn:aws:s3:::your-bucket-name"
}
]
}Enable S3 versioning on buckets that hold important user data ā it lets you recover from accidental deletes. Enable server-side encryption (SSE-S3 or SSE-KMS) ā it is free and takes one checkbox.
The Database Schema
CREATE TABLE uploads (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
s3_key TEXT NOT NULL UNIQUE,
content_type TEXT NOT NULL,
file_size BIGINT,
original_filename TEXT,
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'confirmed', 'deleted')),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
confirmed_at TIMESTAMPTZ
);
CREATE INDEX idx_uploads_user_id ON uploads(user_id);
CREATE INDEX idx_uploads_status ON uploads(status);
-- Clean up pending uploads older than 1 hour (presigned URL expired)
-- Add to a cron job:
-- DELETE FROM uploads WHERE status = 'pending' AND created_at < NOW() - INTERVAL '1 hour';Security Checklist
ā
Never use the original filename as S3 key
ā
Validate MIME type server-side, not just extension
ā
Set ContentLength in presigned URL to prevent size switching
ā
Private files served via presigned download URLs, not public S3 URLs
ā
IAM user has least-privilege S3 policy
ā
S3 versioning enabled on important buckets
ā
Server-side encryption enabled
ā
Multer limits set: fileSize and files count
ā
sharp strips EXIF data on image processing
ā
Pending uploads cleaned up by cron job
Comments (0)
Login to post a comment.