Secure File Uploads in Node.js
File uploads are one of the most dangerous features in web applications. A misconfigured upload flow can lead to remote code execution, path traversal, denial of service, and stored XSS.
Validate File Type by Content, Not Extension
Extensions and MIME types reported by the client are trivially spoofed. Check the actual file content using magic bytes:
import { fileTypeFromBuffer } from "file-type";
const ALLOWED_TYPES = new Set(["image/jpeg", "image/png", "image/webp", "application/pdf"]);
async function validateFileType(buffer: Buffer): Promise<string> {
const result = await fileTypeFromBuffer(buffer);
if (!result || !ALLOWED_TYPES.has(result.mime)) {
throw new Error("File type not allowed");
}
return result.mime;
}
Reference: npm — file-type
Enforce File Size Limits
Set limits at multiple layers:
Reverse proxy (NGINX):
client_max_body_size 10m;
Reference: NGINX — client_max_body_size
Application (Express/Multer):
import multer from "multer";
const upload = multer({
limits: {
fileSize: 10 * 1024 * 1024, // 10 MB
files: 1,
},
});
Reference: npm — multer
Next.js Route Handler:
// app/api/upload/route.ts
export async function POST(request: Request) {
const contentLength = parseInt(request.headers.get("content-length") ?? "0", 10);
if (contentLength > 10 * 1024 * 1024) {
return new Response("File too large", { status: 413 });
}
// proceed
}
Regenerate Filenames
Never use the user-supplied filename for storage. It can contain path traversal sequences like ../../etc/passwd or characters that break file systems:
import crypto from "crypto";
import path from "path";
function generateSafeFilename(originalName: string): string {
const ext = path.extname(originalName).toLowerCase();
const allowedExtensions = new Set([".jpg", ".jpeg", ".png", ".webp", ".pdf"]);
if (!allowedExtensions.has(ext)) {
throw new Error("Extension not allowed");
}
return `${crypto.randomUUID()}${ext}`;
}
Store Outside the Public Directory
Uploaded files should never be directly accessible by URL from the public directory. Instead:
- Store in a dedicated storage bucket (AWS S3, GCP Cloud Storage, Azure Blob).
- Or store on disk outside the web root and serve through an authenticated route handler.
S3 example:
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
const s3 = new S3Client({ region: "eu-west-1" });
async function uploadToS3(buffer: Buffer, key: string, contentType: string) {
await s3.send(new PutObjectCommand({
Bucket: process.env.UPLOAD_BUCKET,
Key: `uploads/${key}`,
Body: buffer,
ContentType: contentType,
ContentDisposition: "attachment", // force download, prevent inline rendering
}));
}
Reference: AWS SDK for JavaScript — S3
Serve Files Safely
When serving uploaded files back to users:
- Set
Content-Disposition: attachmentto force download (prevents browser rendering of HTML/SVG). - Set
Content-Typeto the validated MIME type, not the user-supplied one. - Set
X-Content-Type-Options: nosniffto prevent MIME sniffing. - Never serve uploads from the same origin as your app. Use a separate domain or subdomain (e.g.,
uploads.example.com) so any XSS in an uploaded file cannot access your app's cookies.
// app/api/files/[id]/route.ts
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const file = await getFileFromStorage(id);
return new Response(file.buffer, {
headers: {
"Content-Type": file.contentType,
"Content-Disposition": `attachment; filename="${file.safeName}"`,
"X-Content-Type-Options": "nosniff",
"Cache-Control": "private, no-cache",
},
});
}
Scan for Malware
For high-risk applications, scan uploads before storage:
- ClamAV is open-source and works well in containers: ClamAV — Documentation
- Cloud alternatives: AWS has no native scan, but you can trigger a Lambda with ClamAV. GCP offers Cloud DLP for sensitive content. Azure has Defender for Storage: Azure — Defender for Storage
Image-Specific Hardening
Images are a common upload type but can contain embedded scripts (SVG), EXIF data with PII, or be crafted to exploit image processing libraries.
- Strip EXIF data using sharp:
import sharp from "sharp";
async function processImage(buffer: Buffer): Promise<Buffer> {
return sharp(buffer)
.rotate() // auto-rotate based on EXIF, then strip
.withMetadata({ orientation: undefined })
.toBuffer();
}
- Reject SVG uploads unless you specifically need them. SVG can contain JavaScript.
- Re-encode images through sharp — this destroys any embedded payloads while preserving the image.
Reference: sharp — API
Checklist
- File type validated by content (magic bytes), not extension
- File size limited at proxy and application layer
- Filenames regenerated server-side
- Files stored outside public directory
- Files served from a separate domain or with
Content-Disposition: attachment -
X-Content-Type-Options: nosniffset on all responses - EXIF data stripped from images
- SVGs rejected or sanitized
- Malware scanning in place for high-risk applications
Related Guides
Content is AI-assisted and reviewed by our team, but issues may be missed and best practices evolve rapidly, send corrections to [email protected]. Always consult official documentation and validate key implementation decisions before making design or security choices.
