Back to guides

Secure File Uploads in Node.js

By Davy Rogers

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: attachment to force download (prevents browser rendering of HTML/SVG).
  • Set Content-Type to the validated MIME type, not the user-supplied one.
  • Set X-Content-Type-Options: nosniff to 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: nosniff set on all responses
  • EXIF data stripped from images
  • SVGs rejected or sanitized
  • Malware scanning in place for high-risk applications

Related Guides

Published 04 Mar 2026

Frequently asked questions

Should I trust the client-supplied filename or content-type?
No. Re-derive the type from the file contents (magic bytes / mime sniffing) and generate your own server-side filename. Both fields are attacker-controlled.
Where should uploaded files be stored?
In object storage (S3, R2, GCS) on a separate origin, never inside your application root or anywhere served by the app process. This neutralises path traversal and content-sniffing attacks.
Do I need virus scanning?
If users can share uploads with each other or with staff, yes - ClamAV in-line, or a managed service. For private user-only files, scanning is still strongly recommended.
How do I serve user uploads safely?
Through a signed-URL flow from the dedicated origin, with Content-Disposition: attachment and X-Content-Type-Options: nosniff. Do not let users open arbitrary uploads inline on your main domain.

Related

Want a professional to look at it?Get an AppSec Health Check.