The question this answers
How does a client upload a 500 MB video without that traffic passing through — and tying up — the application servers?
Users upload videos of up to 2 GB from unreliable mobile connections. Proxying them through the API means every upload occupies a request handler for minutes, and a deploy that restarts a pod kills the transfer at 87%.
A time-boxed, narrowly-scoped authorization to write one specific object — issued by your application, redeemed by the client, honoured by the storage service, with your servers never touching the payload.
Why proxying the bytes is the thing that breaks
The obvious design routes the upload through the API: the client POSTs the file, the service streams it into the bucket, everyone is happy at 5 MB. At 500 MB it stops being happy in four separate ways at once, and they are all consequences of the same decision.
A request handler is occupied for the whole transfer, so upload concurrency is bounded by your worker count rather than by anything about storage. Every gigabyte crosses your network path twice — in from the client, out to the bucket — and if the outbound leg goes through a NAT device you are also paying the per-gigabyte processing meter for data that never needed to touch your infrastructure. Load balancers and gateways impose body-size and idle-timeout limits that a slow mobile upload will find. And a rolling deploy, an autoscaling scale-in, or an OOM kill terminates the transfer with no resumable state, because the state lived in a process.
The fix is not a bigger instance. It is to separate the two things the request was conflating: *deciding whether this upload is allowed* — cheap, fast, needs your business logic — and *moving the bytes* — expensive, slow, needs no business logic at all. Object storage can do the second on its own if you hand the client a credential narrow enough to be safe.
app.post('/videos', async (req, res) => {
await requireUser(req)
// request handler is now busy for the length of the upload
await storage.upload({
bucket: 'user-videos',
key: `raw/${req.user.id}/${randomId()}.mp4`,
body: req, // 2 GB streamed through this process
})
res.status(201).json({ ok: true })
})app.post('/videos/upload-url', async (req, res) => {
const user = await requireUser(req)
await assertQuota(user) // business logic lives here
const key = `raw/${user.id}/${randomId()}.mp4`
const url = await storage.signPut({
bucket: 'user-videos',
key, // this exact key, not a prefix
expiresInSeconds: 900,
maxBytes: 2_000_000_000,
contentType: 'video/mp4',
})
await db.uploads.insert({ key, userId: user.id, state: 'pending' })
res.json({ url, key }) // client PUTs the bytes itself
})Both authorize the same upload. The second one never puts a byte of it on your compute, your network path or your NAT meter — and a deploy mid-upload no longer kills the transfer.
The four hops, and the trust boundary each one crosses
The signed authorization is a credential your application mints using its own storage permissions. It encodes the bucket, the exact key, the allowed operation, an expiry, and usually a content type and a maximum size, all covered by a signature the storage service verifies. The client cannot widen any of it: changing the key invalidates the signature.
Notice which boundary the payload crosses and which it does not. The bytes go client → storage, entirely outside your virtual network. Only two small JSON requests touch your application. That is why this architecture also removes the upload path from your load balancer's timeout budget, your pod's memory profile and your egress bill simultaneously.
The same shape runs in reverse for downloads: a signed GET for a private object lets you serve user-scoped media without proxying it and without making the bucket public. The System Design and Architecture material treats this as the general pattern of *authorize centrally, transfer peer-to-peer*; see CDN as Infrastructure for the version where the edge cache serves the object.
The state machine you now own
Direct upload moves work off your servers and moves a problem onto your data model: your application no longer observes the transfer, so it does not know whether it happened. A signed URL issued is not an object stored. The client may abandon the upload, lose connectivity at 90%, or succeed and then crash before telling you.
The honest design records the intent before signing, and confirms it from the storage side rather than from the client. An object-created event — or a HEAD on the key when the client reports completion — is authoritative in a way that a client callback is not. Everything still pending after its signature expired is garbage, and a scheduled sweep deletes it. Skipping the sweep is how buckets fill with fragments of uploads that were never finished.
For genuinely large files, multipart upload adds resumability: the client uploads parts independently, retries individual parts, and completes the object at the end. It also adds the failure mode that bills you — parts from uploads that were never completed persist and are charged as storage until a lifecycle rule aborts them.
- 1Authorize~20 ms
The API checks the session, the quota and the file type, allocates a key, and writes an upload record with state
pending.Signing a prefix instead of an exact key hands the client write access to every object under it.
- 2Sign~5 ms
A short-lived signature is minted over bucket, key, method, expiry, content type and maximum size, using the workload's own storage permission.
A long expiry turns a leaked URL into a long-lived write credential. Minutes, not days.
- 3Transferseconds to minutes
The client
PUTs the bytes straight to the storage endpoint. Your application is not in the path and is free to restart.Client abandons, network drops, or the signature expires mid-transfer on a slow connection.
- 4Confirm~1 s after transfer
An object-created event, or a
HEADon the key, moves the record tostoredwith the real size and entity tag.Trusting a client-reported "done" instead of the storage side — the record says stored and no object exists.
- 5Processseconds
A worker picks up the event, validates the content, generates derivatives, and writes them to a separate prefix.
Validating by file extension rather than by content. See the file-upload security material.
- 6Sweepdaily
A scheduled job deletes
pendingrecords past their expiry and a lifecycle rule aborts incomplete multipart uploads.Never written. The bucket accumulates orphans and unfinished parts forever.
Key points
- Separate authorizing the upload from carrying it: the first needs your business logic, the second needs none of it.
- A signed authorization covers bucket, exact key, method, expiry and usually content type and size limit — the client cannot widen any field without breaking the signature.
- The payload goes client → storage and never enters your network, which removes it from your worker pool, your timeouts and your NAT meter at once.
- You gain a state machine: intent recorded before signing, completion confirmed from the storage side, orphans swept on a schedule.
- Sign a single key, never a prefix, and measure the expiry in minutes.
The loop, answered
Every field is required, which is why no lesson here can recommend something without saying what it costs and what simpler thing to consider first.
- • The client asks the application for permission to upload; the application applies its own authorization and quota rules.
- • The application allocates the exact object key and records a
pendingupload row so the intent survives a crash. - • It mints a signature over (bucket, key, method, expiry, content type, size limit) using the workload identity's storage permission, and returns the URL.
- • The client sends the bytes directly to the storage endpoint; the storage service verifies the signature, enforces the constraints and stores the object.
- • An object-created event, or a
HEADrequest, confirms the write and advances the record; a sweeper deletes anything stillpendingafter expiry.
- • Own the expiry policy. It is the single knob that decides how bad a leaked URL is.
- • Own the key allocation. Never let the client choose the key — that is how one user overwrites another user's object.
- • Own the sweeper and the multipart abort rule; without them the bucket accumulates paid-for garbage.
- • Own content validation after the fact, because the storage service checks the signature, not the file. It will happily store a script named
.jpg.
- • Signature expires mid-transfer on a slow connection: the client sees a 403 at 80% and the whole upload has to restart, which is what multipart resumability exists to fix.
- • Orphaned objects and permanently
pendingrows when the client abandons the upload, both billed until swept. - • A prefix-scoped signature leaks and becomes a write credential for every object under that prefix.
- • CORS is not configured on the bucket, so the browser refuses the direct
PUTwhilecurlsucceeds — a confusing failure that looks like a permissions bug. - • Confirmation trusted from the client: the database says the upload succeeded and there is no object.
- • Upload throughput becomes the storage service's problem, which is the whole point — your API scales on the rate of *authorization* requests, which are small and fast.
- • The dimension that runs out is the signing path only if you sign on every request; caching is not appropriate here, but the operation is cheap and CPU-bound.
- • Very large files need multipart to survive mobile networks; part size trades request count against retry cost.
- • Downstream processing is the real capacity question: a bucket that accepts 500 uploads a minute needs a worker pool and a queue behind it, not a bigger API.
- • The signed URL is a bearer credential. Whoever holds it has exactly the permission it encodes for exactly as long as it lives — treat it like a short-lived token, not like a link.
- • Scope it to one key, one method and one content type, and cap the size, so the worst case of a leak is one overwritten object rather than an open write endpoint.
- • The bucket stays private. Direct upload is not a reason to make it public; the signature is the access mechanism.
- • Validate uploaded content by inspecting it, not by trusting the declared type — a stored file becomes dangerous when something later serves or executes it.
- • The signing identity should be a narrowly-scoped workload role, not a broad storage administrator. See Least Privilege in Infrastructure and Roles vs Static Keys.
- • Removes the double traversal of your network path, which is where the savings actually come from — especially when the outbound leg was crossing a metered NAT device.
- • Shifts request volume onto the storage meter: multipart turns one upload into many
PUToperations. - • Incomplete multipart uploads bill as stored data until aborted, which is the surprise line item of this architecture.
- • Compute cost drops in a way that is easy to miss: request handlers no longer sit idle holding sockets, so the fleet can be smaller.
- • Signed-URL issuance rate versus confirmed object-created events — the gap is your abandonment rate and your orphan rate.
- • Age distribution of
pendingupload records; a growing tail means the sweeper is broken or the expiry is wrong. - • 4xx responses on the storage endpoint, which is where expired signatures and CORS failures show up — and which your own logs will never contain.
- • The signal that lies: your API's latency and error rate. They look excellent precisely because the hard part no longer runs there.
- • Proxy the upload through the application. For files of a few megabytes it is simpler, keeps validation inline, and needs no state machine — do this until the file size or concurrency actually hurts.
- • A managed upload widget or transfer service from the provider, if resumability and progress reporting matter more than controlling the flow.
- • A dedicated upload endpoint on a separate, small service with generous timeouts, when regulatory rules require every byte to pass through infrastructure you control.
- • For internal, server-to-server transfers, no signing at all: the workload already has an identity and can write to the bucket directly.
- • Buys unlimited upload size and concurrency independent of your fleet; costs you a state machine, a sweeper and a confirmation path.
- • Buys a shorter network path and a smaller bill; costs you visibility — you no longer see the transfer, only its start and its end.
- • Buys a narrowly-scoped credential; costs you a new class of secret to reason about, with an expiry that is a genuine security decision.
- • Buys operational calm during deploys; costs the ability to validate content inline, which now happens after the object exists.
Proxied upload vs signed direct upload
client ──body──▶ app ──body──▶ object store
(your bandwidth, your RAM, your request timeout)client ──"may I?"──▶ app (1.8 KB, returns a signature) client ──────body──────▶ object store
PUT https://objects.example/user-uploads/u_8412/9f3c1a.jpg
?X-Expires=300&X-Signature=6b1d… ← HMAC over the line below
resource user-uploads/u_8412/9f3c1a.jpg one key, one bucket — not a prefix
method PUT the same signature cannot GET or DELETE
expiry 300 s after that the store rejects it, no revocation needed
limits content-length-range 0..25MB the store enforces the size, not your handler
content-type image/jpeg declared at signing time and checked on upload
the signature is a bearer credential in a URL: it lands in browser history,
proxy logs and Referer headers — which is exactly why it is scoped to one
key, one method and five minutes instead of being a long-lived key.What people believe, and what is true
Direct upload means the bucket has to be public.
The opposite. The bucket stays private; the signature is a short-lived, single-object credential your application mints.
A signed URL is just a link.
It is a bearer credential. Anyone who obtains it has that exact permission until it expires, which is why expiry and scope are security decisions.
If the client reports success, the object is stored.
Only the storage service knows. Confirm with an object-created event or a HEAD, or your database will confidently reference objects that do not exist.