The first upload feature anyone writes looks like this:
// DON'T
export async function POST(request: Request) {
const formData = await request.formData();
const file = formData.get("file") as File;
const { error } = await supabase.storage
.from("uploads")
.upload(`${crypto.randomUUID()}-${file.name}`, file);
if (error) return Response.json({ error: error.message }, { status: 500 });
return Response.json({ ok: true });
}
It works on your laptop with a 200 KB screenshot. In production it fails in four distinct ways.
Why proxying breaks
The body limit. A serverless function has a request body limit, on Vercel, 4.5 MB. Your users have photos from a modern phone. The failure is a 413 with a message that does not mention file size, and it is not configurable upward past the platform's ceiling.
You pay for the wait. Function time is billed. Receiving 20 MB over a hotel wifi uplink takes a minute, and you are billed for every second of it: to do nothing but hold bytes in memory. Ten concurrent uploads is ten functions doing the same.
Every byte crosses the network twice. Browser → your function → storage. Twice the transfer, twice the latency, and your function's egress is billed too.
Memory. formData() buffers. Several large uploads on one instance is an
out-of-memory kill, which appears as an unexplained 500 for an unrelated request
that happened to land on the same instance.
The fix: sign a URL, upload directly
Three steps:
- The browser asks your server for permission: a small JSON request.
- The server authenticates, decides the key, and returns a signed URL.
- The browser PUTs the bytes straight to storage.
// src/app/api/upload/route.ts
import { authErrorResponse, requireUploader } from "@/lib/storage/authorize";
import { getSignedUploadUrl } from "@/lib/storage";
import { assertUploadable, objectKey, UploadValidationError } from "@/lib/storage/keys";
export async function POST(request: Request) {
let uploader;
try {
uploader = await requireUploader(request); // 1. authenticate, reject cross-origin
} catch (error) {
const denied = authErrorResponse(error);
if (denied) return denied;
throw error;
}
const body = await request.json();
try {
assertUploadable(body); // 2. validate the claim
const key = objectKey({ // 3. derive the key from the SESSION
ownerId: uploader.id,
filename: body.filename,
contentType: body.contentType,
prefix: body.prefix,
});
const signed = await getSignedUploadUrl(key, body.contentType); // 4. only now sign
return Response.json(signed, { headers: { "cache-control": "no-store" } });
} catch (error) {
if (error instanceof UploadValidationError) {
return Response.json({ error: error.message }, { status: error.status });
}
console.error("[upload] signing failed", error);
return Response.json({ error: "Could not prepare the upload." }, { status: 500 });
}
}
That order is the entire security model. Reverse any two steps and you have a hole:
- Sign before authenticating → an open file host on your bill. You find out from the invoice or the abuse report.
- Take the key from the request body → a signed-in user requests
someone-else-id/uploads/invoice.pdfand overwrites it. The key is derived server-side, from the session, and returned to the client. The client never proposes it. - Skip the origin check → any site your signed-in user visits can mint upload URLs against your bucket using their cookie.
The browser half
fetch still has no upload progress event, so a progress bar means
XMLHttpRequest:
function put(signed: { url: string; headers: Record<string, string> }, file: File, onProgress: (p: number) => void) {
return new Promise<void>((resolve, reject) => {
const request = new XMLHttpRequest();
request.open("PUT", signed.url);
for (const [name, value] of Object.entries(signed.headers)) request.setRequestHeader(name, value);
request.upload.addEventListener("progress", (event) => {
if (event.lengthComputable) onProgress(Math.round((event.loaded / event.total) * 100));
});
request.addEventListener("load", () =>
request.status < 300 ? resolve() : reject(new Error(`Upload rejected (${request.status}).`)),
);
request.addEventListener("error", () => reject(new Error("The upload failed.")));
request.send(file);
});
}
Send the content-type you asked to sign for. With Supabase the header is also
what the object is stored as; with S3-compatible providers a mismatch fails the
signature outright.
The claimed size is a claim
The browser tells you the file is 2 MB. Nothing stops it then uploading 5 GB with the URL you just signed.
So there are two limits and you need both:
assertUploadable()in code, which produces a good error message before the user waits for an upload that will be rejected.file_size_limiton the bucket, set in the migration, which is the one that actually holds because the browser talks to storage directly.
Same reasoning for content types: check the allowlist in code, and set
allowed_mime_types on the bucket.
When proxying is right
Not never. Route the bytes through your server when you must inspect them before they are stored:
- virus scanning where the file must never land in the bucket unscanned;
- strict content validation: a CSV whose header row must match a schema;
- files your own code produces (
putObject()), which are not user uploads.
For those, accept the body limit, keep the files small, and be explicit that "under 4 MB" is a product constraint. For scanning larger files, the usual shape is: sign into a quarantine prefix, scan asynchronously, then copy to the real prefix, the bytes still never pass through a function.
Verifying you got it right
- Upload with DevTools open. You should see a small
POST /api/uploadand aPUTto<project>.supabase.co. If a large request goes to your own origin, you are still proxying. - Sign out and POST to
/api/uploadwith curl: expect 401. - POST with an
Originheader from another site: expect 403. - Sign a URL, then try to PUT a file twice as large as you declared. The bucket
limit should reject it, if it does not,
file_size_limitis unset. - Upload a 20 MB file and watch your function logs. The duration should be milliseconds, not the length of the upload.