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
apps/web/app/api/route.ts:22Secure file uploads in Next.js
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.
// 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 });
}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.
Related guides
Storage buckets default-allow read in tutorials. Add RLS policies + signed URLs for downloads.
Row-Level-Security bypass is the most common data leak in vibe-coded apps. Here is exactly how it happens, how attackers find it, and how to fix it in Next.js + Supabase with one policy update.
BOLA is the top item on the OWASP API Security Top 10 for a reason — every AI coding assistant introduces it by default. Learn what it looks like in Next.js, how to exploit it, and how to fix it.
IDOR is the classic name for an authorization bug where a user can change an ID in a URL and access data they should not see. It is BOLA's older cousin and still ships in half of all new apps.