8 min read

Secure file uploads in Next.js — content type, size, storage, serving

File uploads are the most commonly mis-handled feature in AI-built apps. Here is the five-step pattern for uploading user files safely.

Users upload files. Files become attacks — stored XSS, malware distribution, SSRF via URL imports, server overload. This guide walks the five-step secure-upload pattern and the Supabase Storage / S3 specifics.

What it is

A secure file-upload pipeline validates content type server-side, enforces a max size, stores outside the web root, generates a fresh filename, and serves the file with safe headers.

Vulnerable example

// Vulnerable: trusts the client's content-type + filename
export async function POST(req: Request) {
  const form = await req.formData();
  const file = form.get("file") as File;
  await fs.writeFile(`./public/uploads/${file.name}`, await file.arrayBuffer());
  return Response.json({ url: `/uploads/${file.name}` });
}

Fixed example

// Fixed: content sniff + size cap + fresh filename + safe storage
import { randomUUID } from "crypto";
import { fileTypeFromBuffer } from "file-type";

const MAX = 5 * 1024 * 1024; // 5 MB
const ALLOWED = new Set(["image/png", "image/jpeg", "image/webp"]);

export async function POST(req: Request) {
  const form = await req.formData();
  const file = form.get("file") as File;
  if (file.size > MAX) return new Response("too large", { status: 413 });
  const buf = Buffer.from(await file.arrayBuffer());
  const type = await fileTypeFromBuffer(buf);
  if (!type || !ALLOWED.has(type.mime)) {
    return new Response("unsupported type", { status: 415 });
  }
  const key = `${randomUUID()}.${type.ext}`;
  // Upload to Supabase Storage / S3 with a fresh key
  await storage.from("uploads").upload(key, buf, {
    contentType: type.mime,
    cacheControl: "private",
  });
  return Response.json({ key });
}

How Securie catches it

Securie's file-upload specialist detects every FormData / multipart handler and verifies the pipeline: client content-type trust, size cap, storage path, filename scheme. Missing checks become findings with the fix above.

Checklist

  • Content-type validated server-side via magic bytes (file-type package) — never trust the request header
  • Max file size enforced BEFORE buffer allocation
  • Filename regenerated (UUID or hash) — never use client-supplied name
  • Storage outside web-root (S3 / Supabase Storage / Cloudflare R2)
  • Signed URLs with expiration for private files
  • Content-Disposition: attachment for user-uploaded HTML/SVG
  • Antivirus scanning for production uploads (ClamAV, Cloudmersive, etc.)

FAQ

Is a size limit enough?

No. A 1KB SVG file can still contain XSS. Validate content-type too.

What about serving the file later?

Serve user-uploaded HTML / SVG from a separate subdomain (or with Content-Disposition: attachment) so JavaScript in those files cannot read cookies for your app domain.