# 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.

The first upload feature anyone writes looks like this:

```ts
// 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.

```ts
// 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`:

```ts
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.

---

Agentic Boilerplate: A Next.js repo your agent already knows. Free during launch, then $99 once.

- Site map for agents: https://agenticboilerplate.com/llms.txt
- Public API: https://agenticboilerplate.com/openapi.json
- Contact: agenticstudio@gmail.com
