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.