Secure File Upload in Node.js and Next.js: Defenses, Code Examples, and Audit Checklist [2026]
Introduction
File upload is one of the most dangerous features you can add to a web application. A profile picture upload, a document attachment to a support ticket, a CSV import for bulk data — each of these seemingly innocent features opens multiple attack vectors simultaneously.
In 2025, the CVE database recorded over 180 file-upload-related vulnerabilities in web applications, ranging from simple path traversal exploits to critical RCE chains. For startups building Next.js applications with serverless backends, the attack surface is even larger because the serverless boundary often removes traditional filesystem protections.
The core problem: file upload crosses every security boundary your app has. It accepts arbitrary binary data from untrusted clients, stores it on your infrastructure (or in your cloud bucket), processes it through your backend, and potentially serves it back to other users. Each of these steps is a potential exploit point.
In this guide, you'll learn:
- The six most dangerous file upload attack vectors for Node.js and Next.js applications
- Production-grade validation and sanitization code you can copy into your project
- How to scan uploads for malware in serverless and server-based architectures
- How to store files securely (on-disk, S3-compatible, and database)
- A complete audit checklist for your next code review
Six Attack Vectors Every File Upload Feature Faces
Before we write any code, here's what we're defending against:
1. Arbitrary Code Execution (RCE)
An attacker uploads a .js or .ejs file that gets interpreted by your server if it lands in a web-accessible directory. In Next.js, this is mitigated by the public/ directory convention and serverless isolation, but custom Express backends or API routes that write to the filesystem are still vulnerable.
2. Path Traversal
The filename itself is a weapon. An attacker names a file ../../etc/passwd or ../../../config/database.yml. If your code constructs the save path by concatenating a base directory with the user-provided filename, you're writing files to arbitrary locations.
3. Stored XSS via Uploaded Content
Your users can upload SVGs, HTML files, PDFs with embedded JavaScript, or even a carefully crafted image with XSS payloads in EXIF data. If your application serves these files back with a permissive Content-Type, every user who views the uploaded file is at risk.
4. Denial of Service (Storage Exhaustion)
An attacker uploads a 10 GB file (or 10,000 files totalling 10 GB) to fill your disk, your S3 bucket, or your database. Without limits, this is trivial to automate.
5. Malware Distribution
Your legitimate file upload feature becomes a malware distribution channel. Users upload infected PDFs to a shared document repository, and other users download and open them, trusting them because they came from your application.
6. SSRF via File Processing
Your backend processes uploaded files — resizing images with Sharp, extracting text with pdf-parse, generating thumbnails. If the processing library has SSRF vulnerabilities (e.g., a PDF parser that follows external links), the attacker can use your upload endpoint as an SSRF relay.
Architecture Overview: A Defensive File Upload Pipeline
A secure file upload system processes every upload through six sequential gates:
Client Upload → 1. Content-Type Validation → 2. Extension Check
→ 3. Content Inspection → 4. Malware Scan → 5. Safe Storage
→ 6. Sanitized Serving
Let's implement each gate in Node.js and Next.js.
Gate 1: Content-Type Validation (Client + Server)
Never trust the Content-Type header sent by the browser — it's trivial to spoof. Validate on both client and server.
// Client-side (Next.js Client Component — convenience, not security)
"use client";
export function UploadForm() {
const ALLOWED_TYPES = [
"image/jpeg",
"image/png",
"image/webp",
"application/pdf",
"text/csv",
];
function handleFileSelect(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (file && !ALLOWED_TYPES.includes(file.type)) {
alert("Invalid file type");
e.target.value = "";
}
}
return (
<input
type="file"
accept=".jpg,.jpeg,.png,.webp,.pdf,.csv"
onChange={handleFileSelect}
/>
);
}
Server-side validation must re-check because the client-side check is trivial to bypass. Here's a robust validation for a Next.js API route:
// app/api/upload/route.ts
import { NextRequest, NextResponse } from "next/server";
const ALLOWED_TYPES = new Set([
"image/jpeg",
"image/png",
"image/webp",
"application/pdf",
"text/csv",
]);
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
export async function POST(request: NextRequest) {
const formData = await request.formData();
const file = formData.get("file") as File | null;
if (!file) {
return NextResponse.json({ error: "No file provided" }, { status: 400 });
}
// Gate 1: Server-side Content-Type validation
if (!ALLOWED_TYPES.has(file.type)) {
return NextResponse.json(
{ error: `File type '${file.type}' is not allowed` },
{ status: 400 },
);
}
// Gate 2: File size validation
if (file.size > MAX_FILE_SIZE) {
const maxMb = MAX_FILE_SIZE / (1024 * 1024);
return NextResponse.json(
{ error: `File exceeds maximum size of ${maxMb} MB` },
{ status: 413 },
);
}
// Proceed to processing...
const buffer = Buffer.from(await file.arrayBuffer());
// ...
}
⚠️ Critical: The
file.typeproperty comes from theContent-Typeheader. An attacker usingcurl --form "file=@malware.exe;type=image/jpeg"will bypass this check. That's why Gate 3 (content inspection) is mandatory.
Gate 2: Extension Whitelist (Not Blacklist)
Never build an extension blacklist — attackers will find an extension you forgot. Always use a whitelist:
const ALLOWED_EXTENSIONS = new Set([
".jpg", ".jpeg",
".png",
".webp",
".pdf",
".csv",
]);
function validateExtension(filename: string): boolean {
// Get extension, lowercased, and handle multiple dots safely
const dotIndex = filename.lastIndexOf(".");
if (dotIndex === -1) return false;
const ext = filename.slice(dotIndex).toLowerCase();
return ALLOWED_EXTENSIONS.has(ext);
}
Also strip any path traversal characters from the filename:
function sanitizeFilename(filename: string): string {
// Remove path separators and parent directory references
return filename
.replace(/[/\\]/g, "") // Remove / and \
.replace(/\.\./g, "") // Remove ..
.replace(/\0/g, "") // Remove null bytes
.trim();
}
Gate 3: Content Inspection (Magic Bytes)
Content-Type headers lie. File extensions lie. Magic bytes do not. Every file format has a fixed signature at the start of its binary content. Validate that the actual bytes match the claimed type:
// lib/magic-bytes.ts
const MAGIC_BYTES: Record<string, Uint8Array[]> = {
"image/jpeg": [new Uint8Array([0xFF, 0xD8, 0xFF])],
"image/png": [new Uint8Array([0x89, 0x50, 0x4E, 0x47])],
"image/webp": [new Uint8Array([0x52, 0x49, 0x46, 0x46])], // "RIFF" header
"application/pdf": [new Uint8Array([0x25, 0x50, 0x44, 0x46])], // "%PDF"
};
export function validateMagicBytes(
buffer: Buffer,
mimeType: string,
): boolean {
const expectedSignatures = MAGIC_BYTES[mimeType];
if (!expectedSignatures) return false;
return expectedSignatures.some((sig) => {
if (buffer.length < sig.length) return false;
return sig.every((byte, i) => buffer[i] === byte);
});
}
Use it in your upload handler:
// Inside the API route
if (!validateMagicBytes(buffer, file.type)) {
return NextResponse.json(
{ error: "File content does not match claimed type" },
{ status: 400 },
);
}
For PDFs, also validate the PDF structure beyond the %PDF header — attackers can prepend %PDF to an executable:
// Stricter PDF validation: verify the PDF trailer
function validatePdfContent(buffer: Buffer): boolean {
return (
buffer.slice(0, 4).toString() === "%PDF" &&
buffer.slice(-7).toString().trim() === "%%EOF"
);
}
Gate 4: Image Re-encoding (Defense in Depth for Images)
For image uploads, the most effective single defense is re-encoding: decode the image and re-encode it with a trusted library. This strips EXIF data, metadata, and any payload hidden in the image data:
// lib/reencode-image.ts
import sharp from "sharp";
export async function reencodeImage(
buffer: Buffer,
format: "jpeg" | "png" | "webp",
): Promise<Buffer> {
return sharp(buffer)
.ensureAlpha() // Normalize alpha channel
.toFormat(format, {
quality: 85, // Re-encode at fixed quality (strips EXIF)
mozjpeg: true, // Better compression
})
.toBuffer();
}
Why this works: An XSS payload hidden in image metadata (EXIF, XMP) is stripped because
sharpdecodes the image to raw pixel data and encodes a brand-new file. A GIF that passes as JPEG would fail the decode step. A polyglot file that's both a valid ZIP and a valid JPEG —sharpwill reject it if the image decoder can't parse it, or sanitize it if it can.
For PDFs, use a tool like qpdf or pdf-lib to linearize or re-serialize the document, stripping embedded JavaScript:
# Server-side PDF sanitization
qpdf --linearize --remove-embedded-files input.pdf output.pdf
Gate 5: Malware Scanning
For production applications, scan every upload with a malware detection engine. Two practical approaches:
Option A: ClamAV (self-hosted, free)
# Install ClamAV on your server or Docker image
apt-get install clamav clamav-daemon
freshclam # Update virus definitions
// lib/clamav.ts
import { spawn } from "child_process";
export async function scanForMalware(
buffer: Buffer,
): Promise<{ clean: boolean; signature?: string }> {
return new Promise((resolve, reject) => {
const clamscan = spawn("clamscan", ["--stdin", "--no-summary"]);
let output = "";
clamscan.stdout.on("data", (data) => {
output += data.toString();
});
clamscan.on("close", (code) => {
// Exit code 0 = clean, 1 = infected
if (code === 0) resolve({ clean: true });
else if (code === 1) {
const match = output.match(/FOUND\s*$|:\s*(.+?)\s+FOUND/);
resolve({ clean: false, signature: match?.[1] || "Unknown" });
} else {
reject(new Error(`clamscan exited with code ${code}`));
}
});
clamscan.stdin.write(buffer);
clamscan.stdin.end();
});
}
Option B: Serverless-friendly — scan with AWS Lambda + ClamAV layer
For Vercel or serverless Next.js, run ClamAV in a dedicated Lambda function. Upload the file to S3, trigger a Lambda with a ClamAV layer, and reject if infected.
Gate 6: Safe Storage
Never serve user-uploaded files directly from your application domain. The reasons:
- Same-origin XSS: An uploaded SVG with
<script>alert(1)</script>executes in your app's origin if served fromyoursite.com/uploads/virus.svg - Cookie theft: The attacker's script runs in your origin and can read cookies
- CSP bypass: Your CSP allows images from your origin, so the SVG is treated as a valid resource
The solution: store on a separate domain or subdomain.
// lib/storage.ts — S3-compatible storage (MinIO, AWS S3, R2)
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import crypto from "node:crypto";
const s3 = new S3Client({
region: process.env.S3_REGION!,
endpoint: process.env.S3_ENDPOINT, // MinIO or custom endpoint
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY!,
secretAccessKey: process.env.S3_SECRET_KEY!,
},
});
export async function storeFile(buffer: Buffer, originalName: string) {
// Generate a random filename — NEVER use the user-provided name
const ext = originalName.slice(originalName.lastIndexOf(".")).toLowerCase();
const key = `${crypto.randomUUID()}${ext}`;
await s3.send(
new PutObjectCommand({
Bucket: process.env.S3_BUCKET!,
Key: key,
Body: buffer,
ContentType: "application/octet-stream", // Force download, never render
ContentDisposition: "attachment", // Prevent browser from rendering
}),
);
return { key };
}
Key points:
- Random filenames (UUID): prevents enumeration attacks (
/uploads/user-1-profile-pic.jpg)- Content-Type: application/octet-stream + Content-Disposition: attachment: forces download, never renders in-browser
- Separate domain: serve from
cdn.yourstartup.comoryourstartup.ams3.digitaloceanspaces.com- Object-level ACLs: make uploaded objects private, serve via signed URLs or a proxy
For on-disk storage (traditional server):
import { writeFile, mkdir } from "node:fs/promises";
import path from "node:path";
import crypto from "node:crypto";
const UPLOAD_DIR = path.join(
process.cwd(),
"private", // Outside public/ — NOT served by Next.js
"uploads",
);
export async function storeFileOnDisk(buffer: Buffer) {
const key = crypto.randomUUID();
const dir = path.join(UPLOAD_DIR, key.slice(0, 2)); // Shard by first 2 chars
const filepath = path.join(dir, key);
await mkdir(dir, { recursive: true });
await writeFile(filepath, buffer);
return { key, filepath };
}
Serve on-disk files through a dedicated API route that sets safe headers:
// app/api/files/[key]/route.ts
import { NextRequest, NextResponse } from "next/server";
import { readFile } from "node:fs/promises";
import path from "node:path";
const UPLOAD_DIR = path.join(process.cwd(), "private", "uploads");
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ key: string }> },
) {
const { key } = await params;
// Validate key format (no path traversal)
if (!/^[0-9a-f-]+\.[a-z]+$/.test(key)) {
return NextResponse.json({ error: "Invalid key" }, { status: 400 });
}
const filepath = path.join(UPLOAD_DIR, key.slice(0, 2), key);
try {
const buffer = await readFile(filepath);
return new NextResponse(buffer, {
headers: {
"Content-Type": "application/octet-stream",
"Content-Disposition": "attachment", // Force download
"X-Content-Type-Options": "nosniff", // No MIME sniffing
"Cache-Control": "private, max-age=31536000, immutable",
},
});
} catch {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
}
Complete Upload Handler: Putting It All Together
Here's the full production-ready upload handler that integrates all six gates:
// app/api/upload/route.ts
import { NextRequest, NextResponse } from "next/server";
import { validateMagicBytes, reencodeImage, scanForMalware, storeFile } from "@/lib";
const ALLOWED_TYPES = new Set([
"image/jpeg", "image/png", "image/webp", "application/pdf", "text/csv",
]);
const ALLOWED_EXTENSIONS = new Set([
".jpg", ".jpeg", ".png", ".webp", ".pdf", ".csv",
]);
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
export async function POST(request: NextRequest) {
const formData = await request.formData();
const file = formData.get("file") as File | null;
if (!file) return NextResponse.json({ error: "No file" }, { status: 400 });
// Gate 1: Size
if (file.size > MAX_FILE_SIZE) {
return NextResponse.json({ error: "File too large" }, { status: 413 });
}
// Gate 2: Extension
const ext = file.name.slice(file.name.lastIndexOf(".")).toLowerCase();
if (!ALLOWED_EXTENSIONS.has(ext)) {
return NextResponse.json({ error: "Extension not allowed" }, { status: 400 });
}
// Gate 3: Content-Type (baseline)
if (!ALLOWED_TYPES.has(file.type)) {
return NextResponse.json({ error: "Type not allowed" }, { status: 400 });
}
const buffer = Buffer.from(await file.arrayBuffer());
// Gate 4: Magic bytes
if (!validateMagicBytes(buffer, file.type)) {
return NextResponse.json(
{ error: "Content does not match claimed type" },
{ status: 400 },
);
}
// Gate 5 (for images): Re-encode to strip metadata
let finalBuffer = buffer;
if (file.type.startsWith("image/")) {
const format = file.type === "image/png" ? "png" : "webp";
try {
finalBuffer = await reencodeImage(buffer, format as "jpeg" | "png" | "webp");
} catch {
return NextResponse.json({ error: "Invalid image" }, { status: 400 });
}
}
// Gate 6: Malware scan
if (process.env.CLAMAV_ENABLED === "true") {
const result = await scanForMalware(finalBuffer);
if (!result.clean) {
return NextResponse.json(
{ error: "File rejected — malware detected", signature: result.signature },
{ status: 422 },
);
}
}
// Gate 7: Store safely
const { key } = await storeFile(finalBuffer, file.name);
return NextResponse.json({ url: `/api/files/${key}` }, { status: 201 });
}
Next.js Specific: Server Actions and File Upload
If you're using Next.js Server Actions for file upload (App Router), the same principles apply. However, Server Actions have a 4.5 MB body size limit by default. For larger files, use the API route approach above:
// app/upload/_actions.ts
"use server";
export async function uploadFile(formData: FormData) {
const file = formData.get("file") as File;
// ... same validation pipeline as above
}
Limitation: Server Actions in Next.js 16 do not support streaming uploads. For large files (>50 MB), use a dedicated API route with multipart streaming or a client-side S3 signed URL flow.
Preventing Polyglot Files
A polyglot file is a single file that's valid in two formats simultaneously — e.g., a valid ZIP archive that's also a valid JPEG. These can bypass magic-byte checks.
Defense strategies in order of effectiveness:
- Re-encode images (Gate 5 above):
sharpwill fail or sanitize most polyglots because it decodes the image stream rather than reading it sequentially - Validate structural integrity: For PDFs, validate
%%EOFtrailer. For images, confirm dimensions are sensible (e.g.,sharpwill throw if it can't determine image dimensions) - Run separate format validators: Use
pdfinfofor PDFs,exiftoolfor images,filecommand (libmagic) for comprehensive format identification
// Run libmagic as a second opinion
import { file } from "tmp-promise";
import { exec } from "child_process";
import { promisify } from "util";
import { writeFile } from "fs/promises";
const execAsync = promisify(exec);
export async function identifyFileType(buffer: Buffer): Promise<string> {
const { path } = await file({ postfix: ".tmp" });
await writeFile(path, buffer);
const { stdout } = await execAsync(`file --brief --mime-type "${path}"`);
return stdout.trim();
}
Audit Checklist for Your Next Code Review
Copy this checklist into your code review process for any feature that accepts file uploads:
| # | Check | Pass/Fail |
|---|-------|-----------|
| 1 | Extension whitelist (not blacklist) enforced server-side | □ |
| 2 | Content-Type validated server-side (not just client-side) | □ |
| 3 | Magic byte verification against the actual file content | □ |
| 4 | File size limit enforced before reading full file into memory | □ |
| 5 | Image re-encoding with Sharp (or similar) to strip metadata | □ |
| 6 | Malware scanning with ClamAV or cloud service | □ |
| 7 | Randomized filenames — no user-provided names used on disk | □ |
| 8 | Safe serving headers — Content-Type: octet-stream, Content-Disposition: attachment, X-Content-Type-Options: nosniff | □ |
| 9 | Separate domain or subdomain for serving uploads | □ |
| 10 | Path traversal protection — strip ../, ..\\, null bytes from filenames | □ |
| 11 | Rate limiting on upload endpoints to prevent storage exhaustion | □ |
| 12 | Authentication and authorization — verify the uploader has permission | □ |
| 13 | Polyglot file defenses — re-encode or run multiple format validators | □ |
Testing Your Upload Security
Here's a practical test script you can run against your own application:
#!/bin/bash
# test-upload-security.sh
# Test each attack vector against your upload endpoint
URL="${1:-http://localhost:3000/api/upload}"
echo "=== Testing Upload Security ==="
# Test 1: Real file should succeed
echo -n "Test 1 (valid JPEG): "
curl -s -o /dev/null -w "%{http_code}" \
-F "file=@test-data/valid.jpg" "$URL"
echo
# Test 2: Trojan — rename .exe to .jpg
echo -n "Test 2 (exe masked as jpg): "
echo "MZ" > /tmp/test.exe
curl -s -o /dev/null -w "%{http_code}" \
-F "file=@/tmp/test.exe;type=image/jpeg" "$URL"
echo
# Test 3: Path traversal in filename
echo -n "Test 3 (path traversal): "
echo "test" > /tmp/payload.txt
curl -s -o /dev/null -w "%{http_code}" \
-F "file=@/tmp/payload.txt;filename=../../../etc/passwd.txt" "$URL"
echo
# Test 4: SVG with XSS payload
echo -n "Test 4 (XSS in SVG): "
printf '<svg onload="alert(1)">' > /tmp/xss.svg
curl -s -o /dev/null -w "%{http_code}" \
-F "file=@/tmp/xss.svg" "$URL"
echo
# Test 5: Huge file
echo -n "Test 5 (100MB file): "
dd if=/dev/zero bs=1M count=100 of=/tmp/huge.bin 2>/dev/null
curl -s -o /dev/null -w "%{http_code}" \
-F "file=@/tmp/huge.bin" "$URL"
echo
# Test 6: Null byte injection
echo -n "Test 6 (null byte): "
echo "test" > /tmp/evil.txt
curl -s -o /dev/null -w "%{http_code}" \
-F "file=@/tmp/evil.txt;filename=malicious.php%00.jpg" "$URL"
echo
# Test 7: Double extension
echo -n "Test 7 (double extension): "
echo "test" > /tmp/evil.txt
curl -s -o /dev/null -w "%{http_code}" \
-F "file=@/tmp/evil.txt;filename=readme.txt.js.php.jpg" "$URL"
echo
Conclusion
File upload is not inherently dangerous — it's the assumptions we make about uploaded data that create vulnerabilities. Every upload should be treated as untrusted binary input at every stage of the pipeline: validation, processing, storage, and serving.
The six-gate pipeline described here — content validation, magic bytes, image re-encoding, malware scanning, safe storage, and safe serving — provides defense in depth. No single gate is perfect, but together they make exploitation significantly harder.
Add the audit checklist to your code review process. Run the test script before every deployment that touches file upload. And if you need a deeper review of your application's upload security, contact us for a comprehensive security audit.
JS Security Audit
Senior JavaScript security consultants with 10+ years of experience.