File Upload Best Practices: Validation, Storage, and Security

TL;DR

Never trust a file extension or the MIME type the browser sent you. Both are strings the uploader chose. Read the actual bytes, rename the file yourself, keep it out of the web root, and cap the size before any of that runs.

"Every uploaded file is a potential attack vector. Treat uploads like untrusted user input. Validate everything, trust nothing."

Best Practice 1: Validate File Type 3 min

shell.php.jpg has a .jpg extension and will happily report image/jpeg if the uploader says so. Read the bytes instead. The first few of a real JPEG are fixed, and that's what file-type checks.

Complete file validation
import { fileTypeFromBuffer } from 'file-type';

const ALLOWED_TYPES = {
  'image/jpeg': ['.jpg', '.jpeg'],
  'image/png': ['.png'],
  'image/webp': ['.webp'],
  'application/pdf': ['.pdf'],
};
const MAX_SIZE = 10 * 1024 * 1024; // 10MB

async function validateFile(file) {
  const errors = [];

  // Check size
  if (file.size > MAX_SIZE) {
    errors.push('File too large (max 10MB)');
  }

  // Read file buffer to check actual type
  const buffer = await file.arrayBuffer();
  const fileType = await fileTypeFromBuffer(buffer);

  if (!fileType || !ALLOWED_TYPES[fileType.mime]) {
    errors.push('Invalid file type');
    return { valid: false, errors };
  }

  // Verify extension matches actual type
  const ext = file.name.split('.').pop()?.toLowerCase();
  if (!ALLOWED_TYPES[fileType.mime].includes(`.${ext}`)) {
    errors.push('File extension does not match content');
  }

  return {
    valid: errors.length === 0,
    errors,
    mimeType: fileType.mime,
  };
}

Best Practice 2: Generate Safe Filenames 2 min

The filename is user input too. Generate your own from the MIME type you just verified. Keep the original in a database column, where it can only be displayed.

Safe filename generation
import { randomUUID } from 'crypto';
import path from 'path';

function generateSafeFilename(originalName, mimeType) {
  // Get extension from MIME type, not original filename
  const extensions = {
    'image/jpeg': 'jpg',
    'image/png': 'png',
    'image/webp': 'webp',
    'application/pdf': 'pdf',
  };

  const ext = extensions[mimeType];
  const uuid = randomUUID();

  return `${uuid}.${ext}`;

  // WRONG: Using original filename
  // return originalName; // Could be "../../../etc/passwd.jpg"
}

// Store original filename in database if needed
await db.file.create({
  data: {
    storedName: safeFilename,
    originalName: file.name,
    mimeType: fileType.mime,
    size: file.size,
    userId: user.id,
  },
});

Best Practice 3: Store Outside Web Root 3 min

If a file sits at a predictable URL it's public, whatever your app thinks. Store uploads where the web server doesn't serve, and hand them out through an endpoint that checks who's asking.

Secure file storage
// WRONG: Storing in public directory
const uploadPath = './public/uploads/' + filename;
// File accessible at: https://site.com/uploads/file.jpg

// CORRECT: Store outside web root, serve via API
const uploadPath = './private-uploads/' + filename;

// Serve files through authenticated endpoint
app.get('/api/files/:id', authenticate, async (req, res) => {
  const file = await db.file.findUnique({
    where: { id: req.params.id },
  });

  // Check authorization
  if (file.userId !== req.user.id) {
    return res.status(403).json({ error: 'Access denied' });
  }

  const filePath = path.join('./private-uploads', file.storedName);
  res.sendFile(filePath);
});

Best Practice 4: Use Cloud Storage 5 min

S3 and GCS solve the previous two problems for you, as long as the bucket is private. Presigned URLs give a specific person a specific file for a specific hour.

S3 upload with signed URLs
import { S3Client, PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';

const s3 = new S3Client({ region: process.env.AWS_REGION });

async function uploadToS3(file, filename) {
  const command = new PutObjectCommand({
    Bucket: process.env.S3_BUCKET,
    Key: `uploads/${filename}`,
    Body: file,
    ContentType: file.type,
  });

  await s3.send(command);
}

// Generate signed URL for download (temporary access)
async function getDownloadUrl(filename) {
  const command = new GetObjectCommand({
    Bucket: process.env.S3_BUCKET,
    Key: `uploads/${filename}`,
  });

  return getSignedUrl(s3, command, { expiresIn: 3600 }); // 1 hour
}

Best Practice 5: Scan for Malware 4 min

Worth it once you accept documents rather than just images. Re-encoding an image already neutralises most payloads; a PDF or an Office file passes through untouched.

Malware scanning integration
// Using ClamAV
import NodeClam from 'clamscan';

const clamscan = await new NodeClam().init({
  removeInfected: true,
  scanLog: '/var/log/clamscan.log',
});

async function scanFile(filePath) {
  const { isInfected, viruses } = await clamscan.scanFile(filePath);

  if (isInfected) {
    console.warn('Infected file detected:', viruses);
    await fs.unlink(filePath); // Delete infected file
    throw new Error('Malicious file detected');
  }

  return true;
}

Common File Upload Mistakes

MistakeRiskPrevention
Trusting extensionsMalicious file executionCheck actual file content
Using original filenamePath traversal attacksGenerate random names
Storing in web rootDirect file accessStore outside, serve via API
No size limitsDoS via large uploadsEnforce strict size limits
No rate limitingStorage exhaustionLimit uploads per user

External Resources: OWASP maintains two references worth reading in full: the File Upload Cheat Sheet and the Unrestricted File Upload page. Both go deeper on the parser-level attacks this post only gestures at.

Should I resize images on upload?

Yes. Re-encoding through Sharp drops the original bytes, and any embedded payload goes with them. Smaller files and faster pages are the bonus, not the reason.

How do I handle large file uploads?

Multipart with resumable support. Better still, hand the browser a presigned URL so a 2GB file uploads straight to S3 and never touches your server.

Should I keep original filenames?

Keep them in the database so you can show the user what they uploaded. Never let one reach the filesystem. The name on disk is yours to generate.

Further Reading

The upload endpoint is rarely the only thing that needs tightening.

Check Your File Upload Security

Scan your application for file upload vulnerabilities.

Best Practices

File Upload Best Practices: Validation, Storage, and Security