JustAppSec
Back to guides

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: 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


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.

Need help?Get in touch.