Skip to content

Signed upload URLs versus proxying the file through your server

Proxying uploads through a route handler hits body limits, doubles the transfer and bills you for the wait. Sign a URL instead, and get the order of the checks right.

Supabase Storage4 min readships at docs/solutions/supabase-storage/signed-upload-urls-vs-proxying.md

Tags: supabase · storage · uploads · serverless · vercel · security

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:

  1. The browser asks your server for permission: a small JSON request.
  2. The server authenticates, decides the key, and returns a signed URL.
  3. 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.pdf and 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_limit on 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

  1. Upload with DevTools open. You should see a small POST /api/upload and a PUT to <project>.supabase.co. If a large request goes to your own origin, you are still proxying.
  2. Sign out and POST to /api/upload with curl: expect 401.
  3. POST with an Origin header from another site: expect 403.
  4. 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_limit is unset.
  5. Upload a 20 MB file and watch your function logs. The duration should be milliseconds, not the length of the upload.