Skip to content
Innovialabs

Direct File Uploads to Object Storage with Presigned URLs

Advanced4 min

In short

Your server hands the browser a short-lived signed link and the file goes straight to storage, bypassing the backend; size and type limits live in the signature (best via presigned POST), and CORS allows uploads only from your domain.

How it works

The classic "browser → your server → storage" flow makes the backend receive every byte, keep connections open and pay for the traffic twice. A presigned URL is a link carrying a cryptographic signature that your server creates with its own access keys. The browser receives it and uploads the file directly to S3-compatible storage (AWS S3, Cloudflare R2, MinIO and others).

Your keys never reach the client: the signature allows exactly one action on one object, and only until it expires.

Why it beats proxying

Through the serverPresigned URL
Backend loadReceives and forwards the whole fileOnly issues a signature
Request body limitsYou hit proxy and platform limitsStorage limits apply
ServerlessRequest size and execution time limitsWorks without caveats
Speed for the userTwo hopsOne, to the storage endpoint
ControlFull, but expensiveVia signature conditions and post-upload checks

Proxying makes sense only when the file must be processed before it is stored (for example, streaming antivirus scanning) or when storage is not reachable from the internet.

The flow

  1. The client tells your server the file name, type and size.
  2. The server checks the user's permissions, generates the object key itself (never trusting the file name) and signs the link.
  3. The browser uploads the file using the link.
  4. The client reports success, the server verifies the object (HeadObject: size, type) and only then records it in the database.

Expiry and size: use presigned POST

A presigned PUT has an expiry, but you cannot restrict a size range with it. A presigned POST lets you put conditions into a policy that storage enforces itself: size range, content type, key prefix. Example with the AWS SDK for JavaScript v3:

import { S3Client } from "@aws-sdk/client-s3";
import { createPresignedPost } from "@aws-sdk/s3-presigned-post";

const s3 = new S3Client({ region: process.env.S3_REGION });

export async function createUpload(userId: string, contentType: string) {
  const key = `uploads/${userId}/${crypto.randomUUID()}`;
  return createPresignedPost(s3, {
    Bucket: process.env.S3_BUCKET!,
    Key: key,
    Conditions: [
      ["content-length-range", 1, 10 * 1024 * 1024], // up to 10 MB
      ["eq", "$Content-Type", contentType],
    ],
    Fields: { "Content-Type": contentType },
    Expires: 300, // seconds
  });
}

On the client, append every entry from fields to a FormData, and the file last:

const { url, fields } = await fetch("/api/upload").then((r) => r.json());
const form = new FormData();
Object.entries(fields).forEach(([k, v]) => form.append(k, v as string));
form.append("file", file);
await fetch(url, { method: "POST", body: form });

Keep the expiry short: the link only needs to live until the upload starts. Presigned POST support varies across S3-compatible providers, so check yours. If POST is unavailable, use PUT and verify size after upload, deleting objects that exceed the limit.

CORS setup

Without CORS the browser blocks the request to the storage domain. Allow only your own origins and the methods you need:

[
  {
    "AllowedOrigins": ["https://example.com"],
    "AllowedMethods": ["POST", "PUT"],
    "AllowedHeaders": ["*"],
    "ExposeHeaders": ["ETag"],
    "MaxAgeSeconds": 3600
  }
]

ETag belongs in ExposeHeaders if you use multipart uploads: the client has to read it from the response to each part.

Common mistakes

  • Public bucket. Signed uploads do not need public access. Serve files through signed links or a CDN too.
  • Key derived from the file name. It leads to overwriting other objects and character issues. Generate keys on the server.
  • AllowedOrigins: ["*"] in production.
  • Trusting the client. Type and size in the request are only a claim. Verify the object after upload.
  • No cleanup. Abandoned uploads pile up. Add a lifecycle rule for the temporary prefix and for incomplete multipart uploads.

FAQ

Use multipart upload: the file is split into parts, each gets its own signed link, and the server completes the upload at the end. Individual parts can be retried if the connection drops.

We can help with this

If you'd rather get the result than figure it all out yourself — this is what we do every day.

Discuss a project

Read next

Have an idea or a task? Tell us about it

We'll reply within one business day and propose a solution. The consultation is free.