Storage & Managed Data

Direct Uploads and Signed Authorization

The application decides who may upload and where, then gets out of the way: the client sends the bytes straight to object storage with a short-lived signed authorization. Your servers authorize the transfer without ever carrying it.

▶ Run the lab

The question this answers

Infrastructure question

How does a client upload a 500 MB video without that traffic passing through — and tying up — the application servers?

Application requirement

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%.

What it provides

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.

Application RequirementInfrastructure RequirementComputeNetworkStorageIdentityDeploymentScalingReliabilityObservabilitySecurityCostTrade-offs

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.

Proxied: the API carries every byte
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 })
})
Authorized: the API issues permission, the client transfers
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.

Only the two thin arrows touch your infrastructure.PROVIDER-NEUTRAL
Browser / mobile clientpublic
Virtual network
Public subnetpublic
Load balancer :443public— public by design — this is the front door
Private subnetprivate
Upload APIprivate— authorizes; never carries the payload
Upload recordsprivate— pending → complete state machine
Workload roleinternal— the only identity permitted to sign for this prefix
Object storage bucketprivate— private; accepts the signed PUT only
Browser / mobile clientLoad balancer :443· 1. POST /videos/upload-urlcrosses boundary
Load balancer :443Upload API
Upload APIWorkload role· 2. sign with workload identity
Upload APIUpload records· record key, state=pending
Browser / mobile clientObject storage bucket· 3. PUT 2 GB directly (signed)crosses boundary
Object storage bucketUpload API· 4. event: object created

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.

One upload, end to end. Durations are ILLUSTRATIVE.ILLUSTRATIVE
  1. 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.

  2. 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.

  3. 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.

  4. 4Confirm~1 s after transfer

    An object-created event, or a HEAD on the key, moves the record to stored with the real size and entity tag.

    Trusting a client-reported "done" instead of the storage side — the record says stored and no object exists.

  5. 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.

  6. 6Sweepdaily

    A scheduled job deletes pending records 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.

How it works
  • 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 pending upload 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 HEAD request, confirms the write and advances the record; a sweeper deletes anything still pending after expiry.
What you still own
  • 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.
How it fails
  • 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 pending rows 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 PUT while curl succeeds — 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.
How it scales
  • 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.
Security
  • 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.
Cost shape
  • 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 PUT operations.
  • 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.
What to watch
  • Signed-URL issuance rate versus confirmed object-created events — the gap is your abandonment rate and your orphan rate.
  • Age distribution of pending upload 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.
Simpler alternatives
  • 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.
What adopting this costs
  • 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

Upload 1 file: through the application, or straight to the bucket
Same file, same client, same bucket. On the left the bytes pass through your servers; on the right your servers only sign a permission and the client uploads directly.
(a) proxied through the application 2 hops
client ──body──▶ app ──body──▶ object store
              (your bandwidth, your RAM, your request timeout)
wall time
16 s
first byte at store
13 s
RAM held
2.5 GB
uploads per 512 MB pod
8
bytes through your application5,368,709,120 · 5.0 GB
(b) signed authorization, client uploads directly 1 body hop
client ──"may I?"──▶ app        (1.8 KB, returns a signature)
client ──────body──────▶ object store
wall time
13 s
first byte at store
65 ms
RAM held
320.0 KB
uploads per 512 MB pod
~5,000
bytes through your application72,000 · 70.3 KB
What the signature actually authorizes
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.
Your application never needed to touch the bytes. At 64.0 MB × 40 concurrent, the proxy path moves 5.0 GB through your servers — ingress and egress, 74,565× what the signed path moves — and holds 2.5 GB of RAM because the handler buffers the whole body before forwarding it. That is past what a 512 MB pod can hold: at this size and concurrency the proxy path OOM-kills, and the kill takes every other in-flight upload on that pod with it. Buffering also delays the first byte at the store to 13 s — nothing is durable until the whole upload has landed twice, and a request timeout at 16 s loses all of it. Turn buffering off to see streaming recover the latency but keep the bandwidth bill. The signed path costs one 1.8 KB round trip: your application checks who the user is, decides the key, and signs a permission that is narrower than any credential it holds. It is the same trade as a capability token — you stop being the pipe and become the authority.
SIMULATED

What people believe, and what is true

Claim

Direct upload means the bucket has to be public.

Reality

The opposite. The bucket stays private; the signature is a short-lived, single-object credential your application mints.

Claim

A signed URL is just a link.

Reality

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.

Claim

If the client reports success, the object is stored.

Reality

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.

Apply it