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.
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-TypeandContent-Lengthmake 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: attachmentfor 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:
HeadObjectthe key — does it exist, with the expected size and type?- Record it in the database linked to the user, in a
pending → readystate. - 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
Implementing Rate Limiting: Algorithms, Redis, and Response Headers
Fixed window, sliding window, token bucket, and GCRA — how each behaves at the edges, how to implement them atomically in Redis or Postgres, choosing keys behind proxies, fail-open vs fail-closed, headers clients can use, and layered limits for login, APIs, and AI endpoints.
Validating Input With Zod: One Schema for Forms, APIs, and Types
Every trust boundary — request bodies, query strings, webhooks, environment variables, AI output — needs runtime validation TypeScript can't provide. Using Zod schemas at each boundary, sharing them between client and server, stripping unknown keys, and useful errors.