All posts
5 min read

Direct-to-Storage Uploads With Presigned URLs

Proxying uploads through your server wastes memory, bandwidth, and request time. How presigned URLs let browsers upload straight to S3, R2, or GCS: PUT vs POST policies, enforcing size and type, bucket CORS, confirming uploads, multipart, and serving private files.

architectureinfrastructuresecurityintermediate

The first version of file upload in most apps sends the file to your API, which then forwards it to object storage. It works — until users upload 200 MB videos, your serverless function hits its body-size limit, your server's memory spikes, and requests time out. The standard fix is to let the browser upload directly to storage, using a short-lived presigned URL your server issues. (Where to Store User Uploads covers choosing storage.)

The flow

1. Browser → API:      "I want to upload avatar.png, image/png, 1.2 MB"
2. API:                 checks auth, size, type, quota; picks the object key
3. API → Browser:       presigned URL (valid ~5 minutes) + key
4. Browser → Storage:   PUT file directly to the presigned URL
5. Browser → API:      "upload complete: key=…"
6. API:                 verifies the object exists and matches; records it in the DB

Your server never touches the bytes. Storage providers are built to absorb large uploads; your app isn't.

Generating a presigned PUT URL

With the AWS SDK v3 (works with S3 and S3-compatible stores such as Cloudflare R2, with the endpoint configured):

import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3"
import { getSignedUrl } from "@aws-sdk/s3-request-presigner"
import { randomUUID } from "node:crypto"

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

export async function createUpload(userId: string, contentType: string, size: number) {
  if (!["image/png", "image/jpeg", "image/webp"].includes(contentType)) throw new Error("type")
  if (size > 5 * 1024 * 1024) throw new Error("size")

  const key = `uploads/${userId}/${randomUUID()}`
  const url = await getSignedUrl(
    s3,
    new PutObjectCommand({
      Bucket: process.env.S3_BUCKET,
      Key: key,
      ContentType: contentType,
      ContentLength: size,
    }),
    { expiresIn: 300 },
  )
  return { url, key }
}

The browser uploads with the same headers that were signed:

await fetch(url, { method: "PUT", headers: { "Content-Type": file.type }, body: file })

Rules that keep it safe

  • The server chooses the key. Never let the client pick the object path — it could overwrite other users' files or place content where it will be served as something else. Namespace by user or tenant and use random IDs.
  • Short expiry. Minutes, not days. A presigned URL is a bearer credential for that one operation.
  • Authorise before signing. Check the user may upload here and hasn't exceeded quota.
  • Bind type and size into the signature where possible. Signed Content-Type and Content-Length make the upload fail if the client sends something else.

PUT vs POST policies

A presigned PUT URL signs a specific request; enforcing an exact length requires signing it, as above. S3's presigned POST (browser form upload with a signed policy) supports conditions such as content-length-range — a min/max size — and key prefixes, which is more flexible when the exact size isn't known up front. R2 and other S3-compatible services vary in which of these they support; check your provider.

Don't trust the declared type

Content-Type is what the client says. For anything you process or serve back:

  • Verify the real type after upload (magic bytes) in a background job.
  • Serve user content from a separate domain or with Content-Disposition: attachment for non-images, so an "image" that's actually HTML can't run in your origin. (What Is XSS?.)
  • Re-encode images (which also strips metadata like GPS location).
  • Scan for malware if files are shared between users.

Bucket CORS

The browser is making a cross-origin request to the storage domain, so the bucket needs a CORS rule:

[
  {
    "AllowedOrigins": ["https://app.example.com"],
    "AllowedMethods": ["PUT"],
    "AllowedHeaders": ["content-type"],
    "MaxAgeSeconds": 3600
  }
]

Include your local dev origin in a separate dev bucket's rules, not production's. (CORS Errors Explained.)

Confirming the upload

Don't trust step 5 blindly. On completion, the server should:

  1. HeadObject the key — does it exist, with the expected size and type?
  2. Record it in the database linked to the user, in a pending → ready state.
  3. Enqueue processing (thumbnails, scanning). (Your App Needs Background Jobs.)

Alternatively, have storage notify you (S3 event notifications, R2 event notifications) so confirmation doesn't depend on the client.

Clean up orphans: uploads that were never confirmed. A lifecycle rule that expires objects under a pending/ prefix after a day handles it automatically.

Large files: multipart upload

For big files (hundreds of MB and up), use multipart upload: the server initiates it, presigns a URL per part, the browser uploads parts in parallel (and can retry individual parts), then the server completes it. Libraries like Uppy implement the client side. Add a lifecycle rule to abort incomplete multipart uploads, which otherwise accrue storage charges invisibly.

Serving private files

Keep the bucket private and hand out presigned GET URLs after an authorisation check:

const url = await getSignedUrl(s3, new GetObjectCommand({ Bucket, Key: key }), { expiresIn: 60 })

Or put a CDN in front with signed URLs or cookies for heavy traffic. For genuinely public assets (product images), a public bucket or CDN path with long cache headers is fine. (HTTP Caching Headers.)

Checklist

  • Server authorises, validates type/size, and chooses a namespaced random key
  • Presigned URLs expire in minutes; type (and size, where supported) are signed
  • Bucket CORS allows only your origins and required methods
  • Uploads confirmed server-side with HeadObject; DB record created
  • Real type verified; user content served safely
  • Lifecycle rules for pending objects and incomplete multipart uploads
  • Bucket private; downloads via short-lived presigned GETs

EasySpawn runs your API as an always-on server that can sign upload URLs and process files in background workers, with secrets for your storage provider kept server-side. See how it works for AI-built apps or join the waitlist.

Related: Where to Store User Uploads · Secrets Management Beyond .env Files · Validating Input With Zod

Keep reading