Direct-to-Cloud Upload Patterns

A 4 GB video that travels through your API server costs you the bandwidth twice, pins a request handler for the length of the transfer, and turns a routine rolling deploy into a queue of half-finished uploads. Direct-to-cloud patterns take your servers out of the byte path entirely and leave them holding the only two jobs they are actually good at: deciding who is allowed to write, and finding out what landed.

This guide sits under Backend Validation & Cloud Storage Architecture and covers the architecture rather than one vendor’s API. If you want the raw latency and cost numbers for each path, direct S3 uploads vs proxy uploads performance has the benchmark tables; if you are still choosing a provider, S3 vs GCS vs Azure Blob for media uploads compares the three signing models side by side.

Prerequisites

  • [ ] Node 20+ with @aws-sdk/client-s3 3.700 or newer, @aws-sdk/s3-presigned-post and @aws-sdk/s3-request-presigner
  • [ ] A bucket with public access blocked and no bucket policy that grants anonymous s3:PutObject
  • [ ] An IAM role for your API that can s3:PutObject on exactly one prefix, plus s3:AbortMultipartUpload and s3:ListBucket on the same prefix
  • [ ] A working CORS configuration on the bucket that exposes ETag — see CORS configuration for uploads
  • [ ] s3:ObjectCreated:* event notifications routed to SQS, EventBridge or Lambda
  • [ ] Postgres (or any transactional store) you can add one table to
  • [ ] Server clocks disciplined by NTP — signatures die on 15 minutes of drift
  • [ ] curl 7.75+ and a browser with DevTools, for the verification section

How it works

The control plane and the data plane are different networks

Every upload architecture answers one question: which machines touch the bytes? In a proxy upload the answer is “all of them” — the browser streams into your load balancer, which streams into your application process, which streams into the storage SDK, which streams to the bucket. You pay for that traffic twice, you hold a socket open per concurrent upload, and your deploy strategy has to account for transfers that outlive a pod’s grace period.

Direct-to-cloud splits the flow into a control plane and a data plane. Your API stays on the control plane: it authenticates the user, decides the object key, mints a short-lived credential, records intent in a database, and later reacts to what the bucket tells it. The data plane is a single TLS connection from the browser to the storage endpoint, and your code is not on it. The practical consequence is that an upload is no longer a request your server is serving — it is an event your server will hear about.

Proxy upload versus direct-to-cloud upload data path In the proxy path every byte transits the app server before reaching storage; in the direct path the app server only signs a URL and the bytes flow straight from browser to storage. Proxy path: bytes traverse the app server Browser App server Object store full payload full payload every byte is billed twice and holds a request handler open Direct path: the server signs, the bytes bypass it Browser Object store Signing API full payload, one hop, zero server bandwidth ask for a signed credential emits an object-created event
Proxying doubles the bandwidth your servers pay for; direct uploads keep the API on the control plane and out of the data path.

What a signed credential actually is

A presigned URL is not a token your storage provider stores anywhere. It is a deterministic function of the request you intend to make. The SDK builds a canonical string from the HTTP method, the path, the query parameters, the headers you chose to sign, and a payload hash (UNSIGNED-PAYLOAD for browser uploads, because the signer never sees the body). It derives a signing key from your secret access key scoped to date, region and service, then HMAC-SHA256s the canonical string with it. The result is appended as X-Amz-Signature.

https://media-uploads.s3.eu-west-1.amazonaws.com/incoming/acme/01JZQ8W4.mp4
  ?X-Amz-Algorithm=AWS4-HMAC-SHA256
  &X-Amz-Credential=ASIAQ3EXAMPLE7NKJ%2F20260726%2Feu-west-1%2Fs3%2Faws4_request
  &X-Amz-Date=20260726T094512Z
  &X-Amz-Expires=900
  &X-Amz-SignedHeaders=content-type%3Bhost
  &X-Amz-Security-Token=IQoJb3JpZ2luX2VjEDkaCWV1LXdlc3QtMQ
  &X-Amz-Signature=6a2f0c9d1b84e3f57ac0d2b91e4f7c88a5d3e60b1f9c74a2e8d5b0c3f7a1e924

Three properties of that URL decide most of your design. First, nothing is transmitted that is secret — the secret key is an input to the HMAC, never a term in the output, which is why the browser can hold the URL safely. Second, everything in X-Amz-SignedHeaders becomes mandatory: if content-type is signed and the browser sends a different value, or none, S3 answers 403 SignatureDoesNotMatch. Third, the credential is only as narrow as the request you signed — a URL signed for PutObject on one exact key cannot write anywhere else, which is the whole security model. The signing mechanics themselves live in S3 presigned URL workflows; what matters here is that the credential is per-object, per-method and per-minute.

The three-phase handshake, and the gap in the middle

Every direct upload is three phases: ask, transfer, reconcile. The first and third phases hit your API. The second does not, and that gap is the entire operational cost of the pattern. Between the moment the browser starts the PUT and the moment the bucket event arrives, your application has no idea what is happening — not the progress, not the failures, not even whether the user’s laptop is still awake.

Three-phase handshake for a direct upload A sequence diagram across browser, signing API, upload database and object store, showing that the bytes and the success response are exchanged without the application seeing them. Ask, transfer, reconcile Browser Signing API Upload table Object store 1. POST /uploads 2. insert row: pending 3. signed credential + id 4. the bytes — no application code in the path 5. 204 No Content + ETag, seen only by the browser 6. ObjectCreated 7. client says done (a hint) Between step 4 and step 6 your application knows nothing. Step 6 is truth; step 7 is advisory — a tab that closes after a successful PUT still owes you a state transition you will never receive.
The trust gap is structural: the only party that observes a successful transfer is the client, so the authoritative signal has to come from the bucket instead.

Teams that skip this realisation build an endpoint called POST /uploads/:id/complete and treat it as the source of truth. It works in QA and fails in production, because roughly 1–3% of real uploads finish and then never report: the tab is closed, the phone sleeps, the corporate proxy eats the response, or the user hits back. You end up with objects in the bucket that no row references, and rows in pending whose objects exist. Reconciling from s3:ObjectCreated:* costs one Lambda and removes the whole class of bug.

Choosing a pattern

Four patterns cover essentially everything, and the choice is driven by size, by whether you need a progress bar, and by whether anything must be inspected before the bytes land.

Decision tree for choosing a direct-upload pattern A single question branches to four outcomes: presigned POST, presigned PUT, multipart with per-part signing, and proxying through your own API. Which direct-upload pattern to reach for What does the upload need? small file, plain HTML form submit presigned POST size cap enforced progress bar and a cancel button presigned PUT one XHR, up to 5 GB over 100 MB or a flaky connection multipart upload per-part signing must inspect bytes before they land proxy through your own API True in every branch Sign on the server, expire in minutes, and pin the object key — never let the client choose its own path. Confirm the upload from the bucket event, not from the browser's success callback. Expire incomplete multipart uploads with a lifecycle rule, or you pay for parts nobody can read.
Size and observability requirements pick the pattern; the three rules underneath apply whichever branch you land on.
Pattern Ceiling Enforces max size Progress Resumable Round trips to your API
Presigned PUT 5 GiB per object No — only via bucket policy Yes, via XHR upload.onprogress No 1
Presigned POST 5 GiB per object Yes — content-length-range Yes, via XHR on the form body No 1
Multipart, per-part signing 5 TiB, 10,000 parts Per part only Yes, per part Yes 2 + one per batch of parts
Resumable session URI (GCS/Azure) 5 TiB Yes on GCS policy Yes, per chunk Yes 1

Presigned PUT

The simplest thing that works. You sign a PutObjectCommand for one key and the browser does fetch(url, { method: 'PUT', body: file }). There is no way to constrain the object size in the signature, so a client can upload 5 GiB against a URL you issued for a 200 KB avatar. That is fine only if your bucket sits behind other limits; otherwise reach for POST. The trade-offs against relaying through your own server are laid out in presigned URL vs server proxy tradeoffs.

Presigned POST

createPresignedPost produces a URL plus a set of form fields containing a base64 policy document and its signature. The policy can carry conditions the storage service enforces before writing a single byte: content-length-range, an exact or starts-with match on Content-Type, required metadata fields. Violate one and you get 400 EntityTooLarge or 403 Policy Condition failed with no object created. This is the only browser-facing pattern where the size ceiling is genuinely binding, which is why it appears in enforcing upload size limits with S3 POST policies. The full field-by-field comparison lives in presigned POST vs presigned PUT for browser uploads.

Client-orchestrated multipart

Above roughly 100 MB, a single request stops being sensible: one dropped connection costs you the whole transfer. Multipart splits the object into parts of at least 5 MiB (except the last), each uploaded independently and retried independently. The crossover point and the measurements behind it are in multipart vs single-PUT for files under 100MB.

The trap here is @aws-sdk/lib-storage. Its Upload helper is excellent, but it needs a real S3Client with credentials, which in a browser means handing out Cognito or STS credentials rather than a presigned URL — a much wider grant. If you want to stay presigned-only, your server orchestrates the multipart lifecycle and signs each part:

import {
  S3Client,
  CreateMultipartUploadCommand,
  UploadPartCommand,
  CompleteMultipartUploadCommand,
  AbortMultipartUploadCommand
} from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";

const BUCKET = process.env.UPLOAD_BUCKET!;

// requestChecksumCalculation matters: from v3.729 the SDK adds a CRC32 checksum
// header by default, which the browser will not send and the signature demands.
const s3 = new S3Client({
  region: process.env.AWS_REGION,
  requestChecksumCalculation: "WHEN_REQUIRED"
});

export type PartTicket = { partNumber: number; url: string };

export async function beginMultipart(
  key: string,
  contentType: string,
  partCount: number
): Promise<{ uploadId: string; parts: PartTicket[] }> {
  if (partCount < 1 || partCount > 10_000) {
    throw new RangeError(`partCount must be 1..10000, got ${partCount}`);
  }

  const created = await s3.send(
    new CreateMultipartUploadCommand({ Bucket: BUCKET, Key: key, ContentType: contentType })
  );
  const uploadId = created.UploadId;
  if (!uploadId) throw new Error("S3 returned no UploadId");

  const parts: PartTicket[] = [];
  for (let partNumber = 1; partNumber <= partCount; partNumber++) {
    const url = await getSignedUrl(
      s3,
      new UploadPartCommand({ Bucket: BUCKET, Key: key, UploadId: uploadId, PartNumber: partNumber }),
      { expiresIn: 3600 }
    );
    parts.push({ partNumber, url });
  }
  return { uploadId, parts };
}

export async function finishMultipart(
  key: string,
  uploadId: string,
  parts: Array<{ PartNumber: number; ETag: string }>
): Promise<string | undefined> {
  const ordered = [...parts].sort((a, b) => a.PartNumber - b.PartNumber);
  const done = await s3.send(
    new CompleteMultipartUploadCommand({
      Bucket: BUCKET,
      Key: key,
      UploadId: uploadId,
      MultipartUpload: { Parts: ordered }
    })
  );
  return done.ETag;
}

export async function cancelMultipart(key: string, uploadId: string): Promise<void> {
  await s3.send(new AbortMultipartUploadCommand({ Bucket: BUCKET, Key: key, UploadId: uploadId }));
}

Signing 10,000 URLs up front is wasteful — sign in windows of 20 to 50 parts and refill as the client consumes them. The browser side keeps { partNumber, etag } for each completed part and posts the collected list back to finishMultipart. Persisting that list is what makes the upload survive a reload; see persisting upload state in IndexedDB.

Resumable session URIs

Google Cloud Storage and Azure Blob solve the same problem differently. GCS issues a session URI you PUT byte ranges to, and a 308 Resume Incomplete response tells you exactly how many bytes it already holds — resumption needs no client-side bookkeeping at all. Azure stages numbered blocks and commits a block list. Both are covered in uploading to GCS with Node.js client libraries and uploading to Azure Blob with the Storage JS SDK.

Step-by-step implementation

The worked example below is a presigned POST flow with server-owned keys and event-driven reconciliation. It is the pattern I would default to for user-generated media under a gigabyte.

1. Give every upload a row before it has bytes

An object that exists in the bucket but not in your database is unattributable: you cannot tell whose it is, whether it was paid for, or whether to delete it. Create the row first, with the key you are about to sign, and treat the bucket as a cache of that row’s payload.

create table uploads (
  id             uuid primary key,
  tenant_id      text        not null,
  user_id        text        not null,
  object_key     text        not null unique,
  declared_type  text        not null,
  declared_bytes bigint      not null,
  actual_bytes   bigint,
  etag           text,
  state          text        not null default 'pending'
                 check (state in ('pending','stored','verified','published','rejected','expired')),
  created_at     timestamptz not null default now(),
  expires_at     timestamptz not null,
  stored_at      timestamptz
);

create index uploads_pending_expiry on uploads (expires_at) where state = 'pending';
create index uploads_by_tenant on uploads (tenant_id, created_at desc);

The partial index on expires_at is what lets a sweeper find abandoned rows in single-digit milliseconds even when the table holds tens of millions of completed uploads.

2. Issue the narrowest credential that can work

Four rules: the server picks the key, the policy caps the size, the content type is pinned, and the expiry is minutes rather than hours. Everything else is detail.

import { randomUUID } from "node:crypto";
import { S3Client } from "@aws-sdk/client-s3";
import { createPresignedPost } from "@aws-sdk/s3-presigned-post";
import type { PresignedPost } from "@aws-sdk/s3-presigned-post";
import { pool } from "./db.js";

const BUCKET = process.env.UPLOAD_BUCKET!;
const TTL_SECONDS = 900;
const HARD_MAX_BYTES = 2 * 1024 * 1024 * 1024;

const ALLOWED_TYPES = new Map<string, string>([
  ["image/jpeg", "jpg"],
  ["image/png", "png"],
  ["video/mp4", "mp4"],
  ["video/quicktime", "mov"]
]);

export class HttpError extends Error {
  constructor(readonly status: number, message: string) {
    super(message);
    this.name = "HttpError";
  }
}

export type UploadTicket = { uploadId: string; post: PresignedPost; expiresAt: string };

const s3 = new S3Client({
  region: process.env.AWS_REGION,
  requestChecksumCalculation: "WHEN_REQUIRED"
});

export async function issueUploadTicket(input: {
  tenantId: string;
  userId: string;
  declaredType: string;
  declaredBytes: number;
}): Promise<UploadTicket> {
  const extension = ALLOWED_TYPES.get(input.declaredType);
  if (!extension) {
    throw new HttpError(415, `unsupported media type: ${input.declaredType}`);
  }
  if (!Number.isInteger(input.declaredBytes) ||
      input.declaredBytes < 1 ||
      input.declaredBytes > HARD_MAX_BYTES) {
    throw new HttpError(413, `declared size ${input.declaredBytes} outside 1..${HARD_MAX_BYTES}`);
  }

  const uploadId = randomUUID();
  // The server owns the key. The client never sends one, so it can never
  // traverse out of its prefix or overwrite another tenant's object.
  const objectKey = `incoming/${input.tenantId}/${uploadId}.${extension}`;
  const ceiling = Math.min(Math.ceil(input.declaredBytes * 1.05), HARD_MAX_BYTES);

  const post = await createPresignedPost(s3, {
    Bucket: BUCKET,
    Key: objectKey,
    Expires: TTL_SECONDS,
    Conditions: [
      ["content-length-range", 1, ceiling],
      ["eq", "$Content-Type", input.declaredType],
      ["eq", "$x-amz-meta-upload-id", uploadId]
    ],
    Fields: {
      "Content-Type": input.declaredType,
      "x-amz-meta-upload-id": uploadId,
      "x-amz-meta-tenant-id": input.tenantId
    }
  });

  await pool.query(
    `insert into uploads
       (id, tenant_id, user_id, object_key, declared_type, declared_bytes, expires_at)
     values ($1, $2, $3, $4, $5, $6, now() + make_interval(secs => $7))`,
    [uploadId, input.tenantId, input.userId, objectKey,
     input.declaredType, input.declaredBytes, TTL_SECONDS]
  );

  return {
    uploadId,
    post,
    expiresAt: new Date(Date.now() + TTL_SECONDS * 1000).toISOString()
  };
}

The x-amz-meta-upload-id condition is doing quiet but important work: it stamps your primary key onto the object itself, so the reconciler in step 4 can identify the row without parsing the key or trusting the client. Because issuance is now a cheap authenticated endpoint that mints write capability, it needs its own limiter — see rate limiting presigned URL issuance.

A successful call returns:

{
  "uploadId": "7c1a5a1e-6a2f-4f0b-9e35-0f3a2b6d4c11",
  "post": {
    "url": "https://media-uploads.s3.eu-west-1.amazonaws.com/",
    "fields": {
      "Content-Type": "video/mp4",
      "x-amz-meta-upload-id": "7c1a5a1e-6a2f-4f0b-9e35-0f3a2b6d4c11",
      "x-amz-meta-tenant-id": "acme",
      "bucket": "media-uploads",
      "X-Amz-Algorithm": "AWS4-HMAC-SHA256",
      "X-Amz-Credential": "ASIAQ3EXAMPLE7NKJ/20260726/eu-west-1/s3/aws4_request",
      "X-Amz-Date": "20260726T094512Z",
      "X-Amz-Security-Token": "IQoJb3JpZ2luX2VjEDkaCWV1LXdlc3QtMQ",
      "key": "incoming/acme/7c1a5a1e-6a2f-4f0b-9e35-0f3a2b6d4c11.mp4",
      "Policy": "eyJleHBpcmF0aW9uIjoiMjAyNi0wNy0yNlQwOTo0MDoxMloiLCJjb25kaXRpb25zIjpbXX0=",
      "X-Amz-Signature": "9d1c0f4b7a2e6538cd41b0f92a7e4c1d8b53f602e9a7c4185d0b3c6f2a1e7940"
    }
  },
  "expiresAt": "2026-07-26T10:00:12.000Z"
}

3. Send the bytes with progress and a working abort

fetch still cannot report upload progress in any shipping browser, so XMLHttpRequest remains the correct tool for the data plane. Field order matters for POST policies: S3 ignores every field that appears after file, so append the file last.

export type Ticket = {
  uploadId: string;
  post: { url: string; fields: Record<string, string> };
  expiresAt: string;
};

function s3ErrorCode(xml: string): string {
  const match = /<Code>([^<]+)<\/Code>/.exec(xml);
  return match ? match[1] : "UnknownError";
}

export function uploadWithProgress(
  ticket: Ticket,
  file: File,
  onProgress: (fraction: number) => void,
  signal: AbortSignal
): Promise<void> {
  return new Promise((resolve, reject) => {
    const form = new FormData();
    for (const [name, value] of Object.entries(ticket.post.fields)) {
      form.append(name, value);
    }
    form.append("file", file); // must be the final field

    const xhr = new XMLHttpRequest();
    xhr.open("POST", ticket.post.url, true);
    xhr.timeout = 30 * 60 * 1000;

    xhr.upload.onprogress = (event) => {
      if (event.lengthComputable) onProgress(event.loaded / event.total);
    };
    xhr.onload = () => {
      if (xhr.status === 204 || xhr.status === 201) {
        onProgress(1);
        resolve();
      } else {
        reject(new Error(`storage rejected the upload: ${xhr.status} ${s3ErrorCode(xhr.responseText)}`));
      }
    };
    xhr.onerror = () =>
      reject(new Error("network or CORS failure — no response reached the page"));
    xhr.ontimeout = () => reject(new Error("upload timed out after 30 minutes"));

    signal.addEventListener("abort", () => xhr.abort(), { once: true });
    xhr.send(form);
  });
}

xhr.onerror firing with status === 0 is almost always CORS, not connectivity — the browser deliberately hides the response. Diagnose it with fixing CORS preflight errors on S3 uploads rather than by adding retries. Genuine transient failures — 500, 503, SlowDown — should back off; the schedule that works is in implementing exponential backoff for failed chunks.

4. Reconcile from the bucket event, not from the client

This is the step that makes the pattern safe. The bucket emits s3:ObjectCreated:Post (or :Put, or :CompleteMultipartUpload), a small function reads the metadata you stamped in step 2, and the row advances.

State machine for an upload record An upload row moves from pending to stored to verified to published, with expired and quarantined as terminal branches driven by timeouts and failed checks. Lifecycle of an upload record re-issue, same key pending row, no object stored bytes in bucket verified type + scan ok published visible to users event scan copy TTL lapses, no object came signature or scan fails expired swept by the sweeper rejected moved to quarantine Only the bucket event moves a row from pending to stored. A client callback may set an advisory flag; it must never be the authoritative transition.
Six states, and exactly one of the transitions into stored is trustworthy — the one the storage service itself reports.
import { S3Client, HeadObjectCommand, DeleteObjectCommand } from "@aws-sdk/client-s3";
import { pool } from "./db.js";

const s3 = new S3Client({ region: process.env.AWS_REGION });

type S3EventRecord = {
  s3: {
    bucket: { name: string };
    object: { key: string; size: number; eTag: string };
  };
};

export async function handler(event: { Records: S3EventRecord[] }): Promise<void> {
  for (const record of event.Records) {
    // S3 URL-encodes keys and turns spaces into '+'
    const key = decodeURIComponent(record.s3.object.key.replace(/\+/g, " "));
    const bucket = record.s3.bucket.name;

    const head = await s3.send(new HeadObjectCommand({ Bucket: bucket, Key: key }));
    const uploadId = head.Metadata?.["upload-id"]; // SDK lowercases and strips x-amz-meta-

    if (!uploadId) {
      console.error(`orphan object with no upload-id: s3://${bucket}/${key}`);
      await s3.send(new DeleteObjectCommand({ Bucket: bucket, Key: key }));
      continue;
    }

    const updated = await pool.query(
      `update uploads
          set state = 'stored', actual_bytes = $2, etag = $3, stored_at = now()
        where id = $1 and state = 'pending'
        returning declared_bytes`,
      [uploadId, record.s3.object.size, record.s3.object.eTag.replaceAll('"', "")]
    );

    if (updated.rowCount === 0) {
      // Either a duplicate delivery (SQS is at-least-once) or a forged id.
      console.warn(`no pending row for upload ${uploadId}; ignoring ${key}`);
      continue;
    }

    const declared = Number(updated.rows[0].declared_bytes);
    if (record.s3.object.size > declared * 1.05) {
      await pool.query(`update uploads set state = 'rejected' where id = $1`, [uploadId]);
      await s3.send(new DeleteObjectCommand({ Bucket: bucket, Key: key }));
    }
  }
}

The where state = 'pending' clause makes the handler idempotent, which matters because S3 event delivery is at-least-once and duplicate notifications are routine under load. From stored, the object is a candidate for server-side file validation and automated virus scanning integration before anything downstream is allowed to read it.

5. Promote, then hand off to processing

Keep incoming/ hostile and media/ trusted. A verified object is copied — server-side, so no bytes traverse your process — into the trusted prefix, and only then do derivative jobs get queued.

import { S3Client, CopyObjectCommand, DeleteObjectCommand } from "@aws-sdk/client-s3";
import { pool } from "./db.js";

const s3 = new S3Client({ region: process.env.AWS_REGION });
const BUCKET = process.env.UPLOAD_BUCKET!;

export async function promote(uploadId: string): Promise<string> {
  const { rows } = await pool.query(
    `select object_key, tenant_id from uploads where id = $1 and state = 'verified'`,
    [uploadId]
  );
  if (rows.length === 0) throw new Error(`upload ${uploadId} is not in state 'verified'`);

  const source = rows[0].object_key as string;
  const destination = source.replace(/^incoming\//, "media/");

  await s3.send(new CopyObjectCommand({
    Bucket: BUCKET,
    Key: destination,
    CopySource: `${BUCKET}/${encodeURIComponent(source)}`,
    MetadataDirective: "COPY",
    TaggingDirective: "COPY"
  }));
  await s3.send(new DeleteObjectCommand({ Bucket: BUCKET, Key: source }));
  await pool.query(
    `update uploads set state = 'published', object_key = $2 where id = $1`,
    [uploadId, destination]
  );
  return destination;
}

CopyObject handles sources up to 5 GiB; above that you need a multipart copy. With the object in its trusted home, record its dimensions and duration for search — see metadata indexing & search — and enqueue derivatives via post-upload media transcoding.

Configuration reference

Key Type Default Effect
Expires (POST policy) seconds 3600 Deadline for the start of the transfer. 300–900 is right for browser flows.
expiresIn (getSignedUrl) seconds 900 Same for PUT; hard ceiling 604800, but the STS session token expiring kills the URL first.
content-length-range [min, max] none The only client-proof size ceiling. Violation returns EntityTooLarge and writes nothing.
ContentType / $Content-Type string unset Signed into the request; the browser must send exactly this or the signature fails.
requestChecksumCalculation WHEN_SUPPORTED | WHEN_REQUIRED WHEN_SUPPORTED Set to WHEN_REQUIRED when presigning for browsers or non-AWS S3-compatible stores.
partSize bytes 5 MiB Minimum 5 MiB except the final part; raise to 16–32 MiB above 5 GB to stay under 10,000 parts.
queueSize integer 4 Concurrent parts in flight. Above 6 on residential links, throughput flattens and retries rise.
ChecksumAlgorithm CRC32 | SHA256 unset End-to-end integrity; the client must compute and send the matching header.
ServerSideEncryption AES256 | aws:kms bucket default If signed, the browser must echo x-amz-server-side-encryption.
ACL string unset Leave unset. With Object Ownership enforced, any ACL field returns AccessControlListNotSupported.
MaxAgeSeconds (CORS) seconds 0 Preflight cache lifetime. 3000 removes one round trip per part.
ExposeHeaders (CORS) list empty Must include ETag, or multipart completion cannot read part identifiers.
AbortIncompleteMultipartUpload days none Lifecycle rule that reclaims orphaned parts. Set it to 1–7 days on every upload bucket.
forcePathStyle boolean false Required for MinIO and most on-prem S3-compatible endpoints; breaks CORS wildcards if toggled late.

Edge cases and gotchas

The expiry clock starts at signing, not at first byte

X-Amz-Expires is measured from X-Amz-Date. If your SPA fetches a ticket on page load and the user picks a file eleven minutes later, a 900-second URL has 240 seconds of life left for a 700 MB transfer. Request the credential at the moment the transfer begins, not when the form renders, and if the ticket is older than a third of its TTL, throw it away and ask for another. For multipart, note that S3 checks the signature of each part independently as it arrives, so a one-hour part URL genuinely gives you an hour per part — but the multipart upload itself has no expiry at all, which is what the lifecycle rule is for.

Clock skew invalidates signatures that look perfectly valid

If the signing host’s clock drifts more than 15 minutes from the storage service, every request returns 403 RequestTimeTooSkewed with “The difference between the request time and the current time is too large.” The client’s clock is irrelevant — only the signer’s matters — so this shows up as a total outage on one bad instance while the rest of the fleet is fine. Alert on NTP offset, not on error rate.

The SDK signs a checksum header your browser will never send

From @aws-sdk/client-s3 v3.729 the default became requestChecksumCalculation: "WHEN_SUPPORTED", which adds x-amz-sdk-checksum-algorithm: CRC32 and a x-amz-checksum-crc32 header to write operations. When you presign, those headers land in X-Amz-SignedHeaders, the browser does not send them, and you get 403 SignatureDoesNotMatch — or, against Cloudflare R2 and older MinIO builds, 501 Not Implemented: header 'x-amz-sdk-checksum-algorithm'. Set requestChecksumCalculation: "WHEN_REQUIRED" on the client you presign with. If you actually want integrity checking, compute the digest in the browser instead: computing file checksums in the browser with Web Crypto.

Content-Type is signed, and the browser is not on your side

If you sign ContentType: "image/jpeg" and the client sends the file with fetch and no explicit header, the browser derives the type from file.type, which comes from the OS file association and can easily be "" or application/octet-stream. The signature then fails with a 403 that mentions nothing about content types. Two robust options: pin the type in the policy and have the client set it explicitly from the same value it declared, or use ["starts-with", "$Content-Type", "image/"] and validate the real type after the fact from magic bytes.

A multipart ETag is not an MD5 sum

For a single-part upload the ETag is the hex MD5 of the object. For a multipart upload it is the MD5 of the concatenated binary part MD5s, followed by a hyphen and the part count — "a3f2c9d0e1b48576cd21e0f93a7b4c1d-24". Any integrity check that assumes md5(file) === etag will report corruption on every large upload. If you need a whole-object digest, use the SDK’s checksum support or store your own hash alongside the row.

Orphaned parts you are still paying for

Parts uploaded to an incomplete multipart upload do not appear in ListObjectsV2, do not appear in the console’s object list, and are billed at full storage rates indefinitely. A single abandoned 4 GB upload is roughly $1 a year, forever, and a mobile app with a crash loop can accumulate thousands. ListMultipartUploads reveals them; a lifecycle rule removes them automatically, as described in expiring incomplete multipart uploads automatically.

Corporate networks that block the storage endpoint

TLS-intercepting proxies and egress allowlists routinely permit yourapp.com and block *.s3.eu-west-1.amazonaws.com. The failure looks exactly like a CORS error — status === 0, no response — so users report “upload broken” and your logs show nothing at all, because the request never reached anything you own. The fix is a fallback: after two consecutive network-class failures, retry through a proxy endpoint on your own domain, and record which path succeeded so support can see the pattern. Keeping a proxy route alive for 1–2% of traffic is much cheaper than routing 100% through it.

The client’s declared size and type are claims, not facts

declaredBytes and declaredType come from a File object the user’s browser constructed, and both are trivially forged. The POST policy turns the size claim into an enforced ceiling; nothing turns the type claim into a fact. Treat the extension you derived from it as a naming convention only, and let the post-upload validator decide what the object really is. The same reasoning applies to the object key — the moment a client can influence it, you have a path-traversal and cross-tenant overwrite bug.

Event delivery is at-least-once and occasionally slow

S3 notifications usually arrive in under a second, but the guarantee is only “eventually”, and multi-second delays under regional load are normal. Do not build a UI that spins until the row flips to stored; show the upload as complete when the browser sees its 204, and let reconciliation happen behind the scenes. Then make every consumer idempotent, because the same event will be delivered twice more often than you expect.

Verification

Prove the three parts of the flow independently: the ticket is narrow, the transfer works, and the reconciler fires.

Check that the issued policy really caps size and type, and refuses a client-chosen key:

# 1. A ticket for a 1 MB JPEG
curl -s -X POST https://api.example.com/uploads \
  -H 'Authorization: Bearer '"$TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"declaredType":"image/jpeg","declaredBytes":1048576}' | tee ticket.json

# 2. A 12 MB file against that 1 MB ticket must be refused by S3, not by your API.
#    Every signed field has to be replayed verbatim, so build the form from the ticket.
mapfile -t FORM < <(jq -r '.post.fields | to_entries[] | "-F", "\(.key)=\(.value)"' ticket.json)
curl -s -o response.xml -w '%{http_code}\n' \
  "${FORM[@]}" -F "file=@./too-big.jpg" \
  "$(jq -r '.post.url' ticket.json)"
grep -o '<Code>[^<]*</Code>' response.xml
# expected: 400  and  <Code>EntityTooLarge</Code>

# 3. A forged content type must be refused at issuance
curl -s -o /dev/null -w '%{http_code}\n' -X POST https://api.example.com/uploads \
  -H 'Authorization: Bearer '"$TOKEN" -H 'Content-Type: application/json' \
  -d '{"declaredType":"application/x-msdownload","declaredBytes":1024}'
# expected: 415

Then assert the shape of the credential itself, so a change of SDK defaults cannot silently widen it:

import assert from "node:assert/strict";
import test from "node:test";
import { issueUploadTicket } from "./issue-upload-ticket.js";

test("issued tickets are narrow and short-lived", async () => {
  const ticket = await issueUploadTicket({
    tenantId: "acme",
    userId: "u_1",
    declaredType: "video/mp4",
    declaredBytes: 5_000_000
  });

  const policy = JSON.parse(
    Buffer.from(ticket.post.fields["Policy"], "base64").toString("utf8")
  ) as { expiration: string; conditions: unknown[] };

  const lifetimeMs = Date.parse(policy.expiration) - Date.now();
  assert.ok(lifetimeMs <= 900_000, `policy lives ${lifetimeMs}ms — expected <= 900000`);

  const rangeCondition = policy.conditions.find(
    (c) => Array.isArray(c) && c[0] === "content-length-range"
  ) as [string, number, number] | undefined;
  assert.ok(rangeCondition, "no content-length-range condition in the policy");
  assert.ok(rangeCondition[2] <= 5_250_000, "size ceiling is wider than the declared file");

  assert.match(ticket.post.fields["key"], /^incoming\/acme\/[0-9a-f-]{36}\.mp4$/);
  assert.ok(!("acl" in ticket.post.fields), "policy should not set an ACL");
});

Finally, confirm the reconciler by writing straight to the bucket with the AWS CLI and watching the row move — if state reaches stored without any browser involved, the trust gap is genuinely closed:

aws s3api put-object --bucket media-uploads \
  --key "incoming/acme/$(uuidgen).mp4" \
  --body ./sample.mp4 --content-type video/mp4 \
  --metadata upload-id=7c1a5a1e-6a2f-4f0b-9e35-0f3a2b6d4c11,tenant-id=acme

psql -c "select state, actual_bytes, etag from uploads where id = '7c1a5a1e-6a2f-4f0b-9e35-0f3a2b6d4c11'"
# expected within a second or two:  stored | 41288193 | d41d8cd98f00b204e9800998ecf8427e

Frequently Asked Questions

Can I generate the presigned URL in the browser to save a round trip?

No — signing requires the secret access key, so putting the signer in the browser means shipping a credential that can write anything the IAM policy allows, forever. The round trip you save costs 40–80 ms; the credential you leak lasts until someone rotates it. If the extra request genuinely hurts, issue the ticket during the file-picker interaction rather than on form submit.

How do I show a progress bar when my server never sees the bytes?

Progress comes from the client, because the client is the only party measuring the transfer. Use xhr.upload.onprogress for single-request uploads and count completed parts for multipart. If other users or devices need to see that progress, the browser has to report it — real-time upload progress events covers pushing those updates over SSE or WebSockets.

What stops someone reusing a presigned URL a hundred times?

Nothing in the signature — a presigned URL is valid for every request that matches it until it expires. Overwriting the same key is usually harmless because your reconciler only accepts the first transition out of pending, but the write traffic is real. Short expiries, one URL per object key, and a limiter on the issuance endpoint are the controls that matter; the signature is not one of them.

Should the bucket be public if the browser uploads to it directly?

No. Presigned credentials work against a fully private bucket with public access blocked — that is the point of signing. If you ever needed to make the bucket writable by anonymous callers, something is wrong with the signing path, and you have just built an open file drop.

Is direct upload still worth it for small files like avatars?

Often not. Below about 1 MB the extra round trip to get a ticket, plus a CORS preflight, can cost more wall-clock time than simply posting the file to an endpoint you already have warm. Direct upload wins on bandwidth cost, on request-handler occupancy and on files large enough that transfer time dominates — for a 40 KB avatar, none of those apply.