The file upload flow that routes every byte through your API server seems safe. Until the 10MB request body hits your API Gateway limit at the exact moment a paying customer uploads their quarterly report.
When the browser sends that file to your /api/upload endpoint, your server is just a relay — it buffers the entire file before pushing it to S3. Bandwidth paid twice. At any real upload volume, that egress cost shows up on your AWS bill in a way that's hard to defend.
The direct-to-S3 pattern cuts out the middle step. Your server generates a short-lived signed URL; the browser writes directly to S3; your server handles only the bookkeeping.
Why Does Direct-to-S3 Upload Outperform Proxying Through Your Server?

Direct upload cuts your API server out of the upload path entirely; the browser talks to S3 directly using a time-limited presigned URL your server generates. Your server makes two thin API calls (generate URL, confirm completion) and S3 handles all the bandwidth in between.
The five-step production flow:
- Client calls
POST /uploads/requestwith filename, declared MIME type, and file size. - API validates the request (user is authenticated, file type is allowed, tenant quota isn't exceeded), then calls the AWS SDK to generate a presigned PUT URL for a specific S3 key.
- API returns the presigned URL and the S3 object key to the client.
- Client PUTs the file directly to S3 using that URL. No auth headers needed — the HMAC signature is embedded in the URL query parameters as
X-Amz-Signature. That signature covers a specific object key, method, and expiry: the client cannot write to a different path using the same URL. - Client calls
POST /uploads/confirmwith the object key. Your API stores the reference in your database.
For file size: single-PUT presigned URLs handle objects up to 5GiB. Above that, AWS S3's multipart upload API supports up to 10,000 parts per upload, each between 5 MiB and 5 GiB, with a maximum object size of 48.8 TiB. For a SaaS handling documents and images, single-PUT covers every practical case. Video or large data exports over 5GB need the multipart flow — a separate presigned URL per part.
The expiry trap most teams hit in production. According to AWS's presigned URL documentation, the URL expires at whichever comes first: the configured expiry OR when the underlying IAM credential expires. IAM user credentials allow up to 7 days. Temporary credentials (STS AssumeRole, EC2 instance profiles, Lambda execution roles) expire when the role session ends, typically 1–6 hours, regardless of what you set at generation time.
Running on Lambda and set expiresIn: 86400? That URL might expire in 45 minutes when the task role rotates. Teams hit this in staging, chalk it up to a flaky test, then hit it again on a Friday afternoon in production — uploads fail intermittently, nothing in your server logs, because the failure happens entirely on the S3 side.
Fifteen minutes is the right upload URL TTL. Short enough to limit replay risk, long enough for slow connections and files under 500MB.
One CORS note: set AllowedOrigins on your bucket to your specific domain, not ["*"]. Fine locally. A real exposure surface in production.
What Happens After the File Lands?

Files route through three buckets (incoming, clean, and quarantine) with a Lambda scan between the first two determining which direction each upload travels.
Presigned upload URLs always point to incoming/. An S3 event notification triggers a Lambda the moment a new object appears there. The Lambda runs two checks: MIME type validation first, virus scan second. If both pass, the file moves to clean/. Anything suspicious goes to quarantine/. Only clean/ connects to your production application. Users never receive a URL pointing to incoming/ under any circumstances.
ClamAV packaged as a Lambda layer handles files under 100MB, runs on free community virus definitions, and is the standard choice for SaaS products where compliance doesn't mandate a certified scanning service. For FinTech or HealthTech environments, a managed scanning API is the more defensible tradeoff.
MIME validation matters because a user can rename a .exe to .pdf and your frontend extension check catches nothing. The Lambda reads the file's actual magic bytes (the first 4–8 bytes identifying the format regardless of filename), not the Content-Type header the client supplied. Node has file-type; Python has python-magic. Reject anything where the detected type doesn't match the declared type.
Actually — let me back up on scan order. Run MIME validation before the virus scan. It's cheaper and faster, and a MIME mismatch is immediate grounds to quarantine without burning compute on a full AV scan.
One edge case official documentation skips: add a lifecycle rule on incoming/ to auto-delete objects older than 24 hours. If your Lambda times out on a large file, that object sits in incoming/ indefinitely. Six months of production traffic turns an unguarded incoming/ into a graveyard of partially-processed uploads.
How Do You Isolate Files Between Tenants?

Folder-based prefixing is the correct default: every file path includes the tenant's org_id as the first path segment, and your storage policies enforce that users can only access their own prefix.
For Supabase Storage, this is one RLS policy:
create policy "Tenant folder isolation"
on storage.objects for insert
to authenticated
with check (
bucket_id = 'uploads' and
(storage.foldername(name))[1] = (select auth.jwt()->>'org_id')
);
The storage.foldername() helper returns path segments. The first segment must match the org_id in the user's JWT. A user in org abc123 physically cannot write to org456/report.pdf — the database rejects it at the policy layer. If you're using the React + Supabase RLS stack, this is the same JWT-claims pattern you're already applying to row-level data isolation.
For AWS S3 directly: your API controls what key the presigned URL signs for. Set the key to {orgId}/{uuid}/{filename} server-side and never accept a key prefix from the client. The signature covers a specific object key — uploading to a different path with the same URL returns 403.
Production note: according to the Supabase Storage access control documentation, the service role key bypasses RLS entirely. Never pass it to client code. This catches teams because the Supabase SDK makes it easy to accidentally initialize with the service key instead of the anon key — one wrong env var, and your tenant isolation has a hole.
For multi-tenant SaaS at larger scale, bucket-per-tenant gives contractually stronger isolation: separate billing, separate access logs, zero risk of a misconfigured policy bleeding across tenants. Folder prefixing is the right default for most B2B SaaS with up to thousands of tenants. The SaaS MVP stack starts here; bucket-per-tenant is the upgrade for enterprise contracts with explicit data residency clauses.
Signing URLs for Serving — The Part Teams Get Wrong
Making the bucket public and serving S3 object URLs directly is not a secure pattern for multi-tenant file storage.
Public buckets work fine for a marketing site's images. In a B2B product, that approach means tenant A's quarterly report URL is accessible to anyone who knows or guesses the object key. No access control gates the file once it's in the bucket.
The correct pattern: keep the bucket private, generate a signed GET URL when a user requests a file. Time-limit it to 15 minutes to 1 hour for in-app display. When it expires, it returns 403. A URL cached by a churned user from six months ago stops working the moment the TTL passes.
The same credential-expiry trap applies here. If the IAM role used for signed GET URLs is an STS temporary role and your frontend caches those URLs across a full session, some users will hit broken file loads mid-session with no server error to diagnose. The fix: dedicate longer-lived IAM user credentials for URL signing specifically, or add 403-response refresh logic in your fetch layer.
For image variants (thumbnails, previews, resized versions), signed S3 URLs work but are not optimal. Cloudflare Images stores originals and generates variants at the CDN edge: $5 per 100,000 images stored per month and $1 per 100,000 delivered per month. Negligible at early scale. Define up to 20 named variants and Cloudflare handles resizing with no Lambda required.
The self-hosted alternative is imgproxy, a Go-based container that takes URL-encoded transform specs and serves resized images from your origin. Lower ongoing cost at volume, higher operational overhead. If you're on serverless infrastructure and don't want to manage containers, Cloudflare Images is the cleaner default.
What to Build vs What to Buy
For a standard B2B SaaS handling document and image uploads, the production default:
| Layer | Recommended approach |
|---|---|
| Upload path | Direct-to-S3 via presigned PUT URL, 15-min TTL |
| Virus scanning | Lambda + ClamAV layer, files under 100MB |
| MIME validation | Magic bytes check before routing to clean bucket |
| Tenant isolation | Folder prefix: {orgId}/{uuid}/{filename} |
| Serving | Private bucket + signed GET URL, 15–60 min TTL |
| Image variants | Cloudflare Images or imgproxy |
Supabase Storage wraps most of this in a managed surface — S3 under the hood, presigned URLs via SDK, tenant isolation through your existing RLS policies. What it doesn't include: virus scanning. You wire that yourself with a Lambda or webhook triggered by new objects in the incoming path.
The BookBed case study is a useful contrast: BookBed uses Firebase Storage with folder-prefix isolation for user profile photos — small JPEGs from authenticated users, a hard 200KB size cap, no virus scanning needed. That threat model is different from arbitrary document upload in an enterprise SaaS where untrusted users upload unknown file types. The architecture in the table above scales to the harder case.
You've probably felt this before. You implement file upload in a day, ship it, then three weeks later realize the CORS policy is *, the bucket might be public, and nothing is scanning uploads. The architecture above takes a week to implement properly. Your multi-tenant SaaS product looks completely different on the other side of it.
What's your current upload flow? If bytes still route through your API server, that migration is a Thursday afternoon project. Your egress bill will confirm it was worth the time.
