Tech Stack25 June 2026 · 9 min read

SaaS File Upload Architecture: Direct-to-S3 With Presigned URLs

Direct-to-S3 upload with presigned URLs, three-bucket virus scanning, tenant folder isolation, and signed serving — the production file upload architecture for B2B SaaS.

SaaS File Upload Architecture: Direct-to-S3 With Presigned URLs

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?

A sculptural still life of a bright cyan key resting in a groove between two pale concrete blocks on a warm beige surface, representing direct transfer of a signed credential between endpoints, dramatic directional light, deep umber shadows, no text

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:

  1. Client calls POST /uploads/request with filename, declared MIME type, and file size.
  2. 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.
  3. API returns the presigned URL and the S3 object key to the client.
  4. 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.
  5. Client calls POST /uploads/confirm with 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?

A sculptural still life of three concrete slabs arranged as raw, polished, and shadowed stages with a bright cyan sphere on the polished center slab, representing a three-stage file scanning pipeline on a warm beige surface, museum-quality directional light

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?

A sculptural still life of four concrete compartments arranged in a grid, three holding grey cubes and one holding a bright cyan sphere as the focal point, representing isolated tenant data compartments on a warm beige surface, top-down directional lighting

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.

DL

Dusko Licanin

Full-Stack Developer · Banja Luka, Bosnia

Full-stack developer shipping SaaS MVPs, web apps, and mobile apps 2× faster than agencies using AI-augmented workflows. Live portfolio: BookBed, Callidus, Pizzeria Bestek.

Frequently Asked Questions

How does a direct-to-S3 file upload with a presigned URL work?

Your backend generates a presigned PUT URL tied to a specific S3 key and returns it to the client. The client PUTs the file directly to S3 using that URL — the server never touches the bytes. The HMAC signature embedded in the URL's query string covers the HTTP method, bucket, key, expiry, and declared content type, so S3 validates the upload without the client holding any AWS credentials. After the upload completes, the client notifies your backend with the object key, and your backend records the reference. The server handles two small API calls; S3 carries all the bandwidth.

What is the upload size limit for S3 presigned URLs?

A single-PUT presigned URL handles objects up to 5GiB without special configuration. For larger files, [AWS S3's multipart upload API](https://docs.aws.amazon.com/AmazonS3/latest/userguide/qfacts.html) supports up to 10,000 parts per upload, with each part ranging from 5MiB to 5GiB, and a maximum object size of 48.8TiB. For most SaaS applications handling documents, spreadsheets, and images, the single-PUT limit covers every realistic case. Multipart presigned uploads become necessary when your product handles video files, large backups, or bulk data exports above 5GiB. The multipart flow generates a separate presigned URL for each part, which the client uploads before calling the S3 CompleteMultipartUpload API to assemble the final object.

What is the right file storage architecture for a B2B SaaS product?

The production-grade pattern for SaaS file storage is: direct-to-S3 upload via presigned PUT URL, a three-bucket scanning pipeline (incoming, clean, quarantine), folder-prefix tenant isolation using the org ID as the first path segment, and time-limited signed GET URLs for serving. Keep the bucket private; never serve files via public bucket URLs. For image variants, [Cloudflare Images](https://developers.cloudflare.com/images/) or imgproxy generates resized thumbnails on demand without storing multiple copies. The right managed option depends on your stack: Supabase Storage handles upload and serving cleanly with RLS for tenant isolation, but you wire virus scanning separately with a Lambda webhook triggered by new objects in the incoming path.

How do you configure Supabase Storage RLS for a multi-tenant application?

Create an RLS policy on `storage.objects` that checks the first path segment against the user's `org_id` JWT claim using the `storage.foldername()` helper. The policy evaluates at INSERT time and rejects any upload whose first path segment doesn't match the current user's org. Apply equivalent SELECT, UPDATE, and DELETE policies to lock down read and delete access. The most common mistake: using the service role key in client code. The [Supabase Storage access control docs](https://supabase.com/docs/guides/storage/security/access-control) state explicitly that the service key bypasses RLS entirely — a single misconfigured client initialization exposes all tenant files. Use the anon key in client code and rely on authenticated user sessions for your RLS policies to enforce tenant scoping.

Why does my S3 presigned URL expire earlier than the expiry I configured?

Presigned URLs expire at whichever comes first: the configured expiry time or the expiration of the underlying IAM credentials. If your backend generates presigned URLs using a Lambda execution role, ECS task role, or any STS AssumeRole credential, the URL silently expires when that credential rotates — typically every 1–6 hours for temporary credentials, regardless of the `expiresIn` value you set. The fix: either use longer-lived IAM user credentials (not a task role) for URL signing, or add 403-response refresh logic in your client's file request layer to fetch a new signed URL on failure. According to [AWS's presigned URL docs](https://docs.aws.amazon.com/AmazonS3/latest/userguide/using-presigned-url.html), IAM user credentials allow up to a 7-day maximum expiry — the longest available option.