Presigned URL vs Server Proxy Tradeoffs

A presigned PUT sends the file straight from the browser to object storage, sparing your API server the bandwidth and memory; a server proxy routes the file through your API first, giving you a synchronous chokepoint to validate before anything lands in storage. Choose presigned for scale and cost, proxy for inline control.

This decision guide sits within S3 presigned URL workflows under Backend Validation & Cloud Storage Architecture, and it assumes you have already read the raw numbers in direct S3 uploads vs proxy uploads. What follows is the decision itself: the mechanism behind each path, the parameters you actually set, and the failure modes that decide the argument in production.

When to use this approach

  • Choose presigned when the median file is over about 10 MB, when upload concurrency is unpredictable, or when your API runs on a platform with a hard request-body ceiling (API Gateway stops at 10 MB, most edge runtimes far lower).
  • Choose a proxy when a bad file must be rejected in the same HTTP response the user is waiting on — a CSV that must parse, an avatar that must be re-encoded, a document you rewrite before storing.
  • Choose the hybrid — presign the transport, validate asynchronously — when files are large and untrusted, which describes almost every media product.

Prerequisites

  1. Node 20.11 or later, @aws-sdk/client-s3 and @aws-sdk/s3-request-presigner at 3.600.0 or later, plus @aws-sdk/lib-storage if you intend to proxy.
  2. AWS_REGION and UPLOAD_BUCKET in the environment; credentials from an instance or task role, never static keys in the signing service.
  3. An IAM policy on the signing role that is narrower than the bucket:
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "SignUploadsIntoQuarantineOnly",
      "Effect": "Allow",
      "Action": "s3:PutObject",
      "Resource": "arn:aws:s3:::media-uploads-prod/quarantine/*"
    }
  ]
}

A presigned URL can never grant more than the signing principal holds, so this single statement caps the blast radius of a leaked URL to one write into one prefix.

How each path works underneath

The presigned path: a signature travelling in the query string

Signing is an offline computation. getSignedUrl builds the canonical request — method, URI-encoded key, sorted query parameters, the headers you chose to sign, and the payload hash UNSIGNED-PAYLOAD — hashes it, derives a date-and-region-scoped key from your secret with four nested HMAC-SHA256 rounds, and appends the result as X-Amz-Signature. No network call to AWS happens. Your service can mint thousands of URLs a second on a single core, which is why signing cost is effectively zero and why rate limiting presigned URL issuance is a policy decision rather than a capacity one.

When the browser’s PUT arrives, S3 reconstructs the same canonical request from what it actually received and recomputes the HMAC. Everything in the diagram below must line up byte for byte, or the object is never written.

What S3 checks when a presigned PUT arrives Four query parameters on the left, each mapped by an arrow to the corresponding check S3 performs on the right, with a note that any mismatch returns 403 before your API is involved. Signature verification happens entirely inside S3 X-Amz-Credential Key id plus region and date scope must match X-Amz-Date + Expires Window still open, ceiling 604800 s X-Amz-SignedHeaders Every listed header arrives byte-identical X-Amz-Signature HMAC recomputed over the canonical request Any mismatch: 403 SignatureDoesNotMatch Your API never sees the request, so it cannot repair it.
The signature is verified in S3, so a signing mistake surfaces as a client-side 403 with no server log on your side.

The proxy path: your process owns the socket

A proxy is not just an extra hop, it is an extra stateful hop. Your runtime accepts a TCP connection, terminates TLS, reads the request body, and opens a second connection outbound. If you use @aws-sdk/lib-storage, the bytes are chunked into parts and held in memory until each part is acknowledged, so resident memory per in-flight upload is roughly partSize × queueSize — 32 MB with the defaults used below. Twelve concurrent 500 MB uploads on one 512 MB container is not a slow request, it is an OOM kill. The safe implementation streams with backpressure rather than buffering, the same discipline described in streaming file uploads in Node.js with web streams.

The two paths side by side

Factor Direct presigned PUT Server proxy
File data path Browser → storage Browser → API → storage
API bandwidth used Zero for the file body Full file size, twice (in + out)
API memory per upload ~0 (a 2 KB string) partSize × queueSize, typically 32 MB
Latency One hop to storage Two hops, serialized
Validation point After upload (async) Before upload (synchronous)
Credential exposure Short-lived signed URL None reaches the client
Body-size ceiling 5 GB per PUT Whatever your gateway allows
CORS required Yes (browser cross-origin) No (server-to-server)
Best for Large media, high volume Small files needing inline checks

Latency and connection occupancy

A presigned PUT is a single network hop. A proxy is two serialized hops — the file travels its own size twice before it is durable — and, more expensively, it pins one of your server’s connections for the entire transfer.

Presigned direct path versus proxy path Top row: browser uploads directly to storage in one hop. Bottom row: browser uploads to the API which forwards to storage, moving the body twice. Presigned: one hop Browser file body (1x) Storage Proxy: two hops Browser body (1x) API validate here body (2x) Storage Proxy moves the file body through the API; presigned never touches it.
The presigned path is a single hop to storage; the proxy path moves the file body through the API server twice.

Occupancy is the number that breaks capacity planning. On a 40 Mbit/s uplink a 200 MB video takes about 42 seconds to leave the client. On the presigned path your API is busy for the 40 ms it takes to sign; on the proxy path it is busy for all 42 seconds, holding a socket, a TLS session and a stream buffer the whole time.

API connection occupancy for one 200 MB upload A timeline to 45 seconds. The presigned row shows a 40 millisecond sign call followed by idle API time; the proxy row shows the API socket held for the full 42 seconds. One 200 MB upload on a 40 Mbit/s link 40 ms sign call browser to S3 direct, your API is idle Presigned API socket, TLS session and buffers held 42 s Proxy Same bytes over the same link: what differs is who waits. 0 s 10 s 20 s 30 s 40 s
Occupancy, not bandwidth, is what forces a proxy fleet to scale: 1000 concurrent uploads means 1000 pinned connections.

At 1000 concurrent uploads the presigned service needs roughly 40 requests per second of signing capacity — one small container. The proxy needs 1000 live connections and, at 32 MB of part buffers each, 32 GB of resident memory. That gap, not the per-gigabyte transfer price, is the real cost difference.

Validation point

This is the proxy’s one decisive advantage. Because every byte passes through your code you can sniff the magic bytes, enforce a size, scan the content and reject with a clean 422 before anything is stored — the technique in validating file signatures with libmagic. The presigned path stores first and validates afterwards, so you need an event-driven pipeline such as serverless virus scanning with AWS Lambda.

Security cuts the other way. A proxy never exposes a storage credential; a presigned URL deliberately hands the client a scoped, time-bound one, so the whole model rests on signing narrowly, as generating secure presigned URLs sets out. Both are safe when done right; the presigned model simply has more knobs to get wrong.

Implementation

The pragmatic answer is to implement both behind one planner and let file size pick the path. This module is complete: it decides, signs, and proxies.

import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { Upload } from "@aws-sdk/lib-storage";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { randomUUID } from "node:crypto";
import type { Readable } from "node:stream";

const region = process.env.AWS_REGION;
const bucket = process.env.UPLOAD_BUCKET;
if (!region || !bucket) throw new Error("AWS_REGION and UPLOAD_BUCKET must be set");

const s3 = new S3Client({ region });

/** Anything larger than this is not worth pushing through the API process. */
const PROXY_LIMIT_BYTES = 10 * 1024 * 1024;
/** Refuse absurd declarations before we sign anything. */
const MAX_BYTES = 5 * 1024 * 1024 * 1024;

export interface UploadIntent {
  ownerId: string;
  contentType: string;
  contentLength: number;
  /** true when the caller must get an accept/reject verdict in this response */
  needsSyncVerdict: boolean;
}

export function planUpload(intent: UploadIntent): "presign" | "proxy" {
  if (intent.contentLength <= 0 || intent.contentLength > MAX_BYTES) {
    throw new RangeError(`contentLength ${intent.contentLength} out of range`);
  }
  return intent.needsSyncVerdict && intent.contentLength <= PROXY_LIMIT_BYTES
    ? "proxy"
    : "presign";
}

function quarantineKey(ownerId: string): string {
  return `quarantine/${ownerId}/${randomUUID()}`;
}

export async function issuePresignedPut(intent: UploadIntent) {
  const key = quarantineKey(intent.ownerId);
  const command = new PutObjectCommand({
    Bucket: bucket,
    Key: key,
    ContentType: intent.contentType,
    ContentLength: intent.contentLength, // binds the exact byte count
    ChecksumAlgorithm: "CRC32",          // S3 rejects a corrupted body
    Metadata: { "owner-id": intent.ownerId },
  });
  const url = await getSignedUrl(s3, command, {
    expiresIn: 900,
    signableHeaders: new Set(["content-type", "content-length"]),
  });
  return { path: "presign" as const, url, key, expiresIn: 900 };
}

export async function proxyUpload(intent: UploadIntent, body: Readable) {
  const key = quarantineKey(intent.ownerId);
  const upload = new Upload({
    client: s3,
    params: {
      Bucket: bucket,
      Key: key,
      Body: body,                 // streamed, never buffered whole
      ContentType: intent.contentType,
      Metadata: { "owner-id": intent.ownerId },
    },
    partSize: 8 * 1024 * 1024,
    queueSize: 4,                 // 8 MB x 4 = 32 MB resident, per upload
    leavePartsOnError: false,     // abort so orphan parts are not billed
  });
  upload.on("httpUploadProgress", (p) => {
    console.log(`${key} ${p.loaded ?? 0}/${p.total ?? intent.contentLength}`);
  });
  await upload.done();
  return { path: "proxy" as const, key };
}

Reading the critical parameters in order:

  • ContentLength in the command is what makes the presigned URL a size cap rather than an open door. Because it is also in signableHeaders, a client that sends 900 MB against a URL signed for 4 MB gets a 403, not a stored object.
  • ChecksumAlgorithm: "CRC32" makes S3 verify integrity on write; pair it with a client-side digest from computing file checksums in the browser if you need end-to-end assurance.
  • expiresIn: 900 is fifteen minutes — long enough for a slow mobile link, short enough that a URL leaked into a log or a Slack paste is dead before anyone acts on it.
  • partSize × queueSize is the proxy’s memory contract. Multiply it by peak concurrency and compare against your container limit before you deploy.
  • leavePartsOnError: false aborts the multipart upload when the stream dies. Without it you accumulate billable orphan parts; a lifecycle rule from S3 lifecycle rules for temporary uploads is the backstop, not the fix.
  • Both paths write to quarantine/, which keeps the hybrid open: nothing is servable until something promotes it.

Promoting a clean object

The hybrid recovers the proxy’s guarantee asynchronously. The browser uploads directly into the quarantine prefix, an ObjectCreated event triggers the checks, and only a clean object moves to the serving prefix — the layout described in quarantine bucket patterns for infected uploads.

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

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

export async function promoteIfClean(
  bucket: string,
  key: string,
  isClean: boolean,
): Promise<string | null> {
  if (!isClean) {
    await s3.send(new DeleteObjectCommand({ Bucket: bucket, Key: key }));
    return null;
  }
  const target = key.replace(/^quarantine\//, "ready/");
  await s3.send(
    new CopyObjectCommand({
      Bucket: bucket,
      CopySource: encodeURI(`${bucket}/${key}`),
      Key: target,
      MetadataDirective: "COPY",
    }),
  );
  await s3.send(new DeleteObjectCommand({ Bucket: bucket, Key: key }));
  return target;
}

encodeURI on CopySource matters: an unencoded space or + in the key produces NoSuchKey on a file that plainly exists.

Configuration reference

Option Type Default Effect
expiresIn number (s) 900 Validity window of the signed URL. Hard ceiling 604800 with long-lived keys.
signableHeaders Set<string> {host} Headers bound into the signature. Every entry must be reproduced exactly by the client.
ContentLength number unset When signed, fixes the body size. Unset means any size up to 5 GB.
ChecksumAlgorithm string unset CRC32, SHA256 and friends; S3 rejects a body whose digest disagrees.
partSize number (B) 5242880 Proxy only. Chunk size for multipart; minimum 5 MB except the final part.
queueSize number 4 Proxy only. Parts uploaded in parallel. Memory is partSize × queueSize.
leavePartsOnError boolean false Keep incomplete parts after a failure. Leave false unless you resume manually.
client_max_body_size nginx size 1m Proxy only. Caps the body your reverse proxy will forward at all.

Configuration gotchas

413 at the reverse proxy, before your handler runs

A proxy upload of anything over 1 MB dies at nginx with 413 Request Entity Too Large and never reaches Node, because client_max_body_size defaults to 1m. Cloudflare adds its own 100 MB ceiling on the free plan. Raise both as described in raising nginx and Cloudflare upload size limits, and handle the client side per handling 413 and 507 errors during uploads. This limit alone pushes many teams onto presigned URLs.

SignatureDoesNotMatch from a header you signed but the browser rewrote

The response body reads The request signature we calculated does not match the signature you provided. Check your key and signing method. The usual cause is signing content-type and then uploading a Blob with no type, so fetch sends text/plain;charset=UTF-8. Either set the type on the Blob or drop content-type from signableHeaders — but not both, or you lose the type binding.

CORS blocks the PUT before a byte moves

Direct uploads are cross-origin, so the browser sends an OPTIONS preflight and reports Access to fetch at 'https://bucket.s3.amazonaws.com/...' from origin 'https://app.example.com' has been blocked by CORS policy: Response to preflight request doesn't pass access control check. The bucket CORS rule must allow PUT, your exact origin, and every header you signed; see fixing CORS preflight errors on S3 uploads. A proxy has no such problem — same origin, no preflight.

The proxy OOMs under concurrency, not under size

A single 2 GB upload streams fine; thirty simultaneous 200 MB uploads kill the container with FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory. Cap in-flight proxy uploads with a semaphore sized to containerMemory / (partSize × queueSize) and return 503 with a Retry-After header beyond it.

Verification

Prove the presigned path actually bypasses your API — the request must terminate at the storage host:

curl -sS -o /dev/null \
  -w 'status=%{http_code} time=%{time_total}s host=%{remote_ip}\n' \
  -X PUT --data-binary @sample.mp4 \
  -H 'Content-Type: video/mp4' \
  "$PRESIGNED_URL"
# status=200 time=4.812s host=52.218.x.x   <- an S3 address, not your ALB

Then confirm the object landed with the bindings you signed:

import { S3Client, HeadObjectCommand } from "@aws-sdk/client-s3";
import assert from "node:assert/strict";

const bucket = process.env.UPLOAD_BUCKET;
if (!bucket) throw new Error("UPLOAD_BUCKET is not set");
const s3 = new S3Client({ region: process.env.AWS_REGION });

const head = await s3.send(
  new HeadObjectCommand({ Bucket: bucket, Key: process.argv[2] }),
);

assert.equal(head.ContentType, "video/mp4");
assert.ok((head.ContentLength ?? 0) > 0, "object is empty");
assert.ok(head.ChecksumCRC32, "checksum was not recorded");
console.log(`ok: ${head.ContentLength} bytes, etag ${head.ETag}`);

For the proxy path, watch process.memoryUsage().rss while pushing ten concurrent uploads. If RSS tracks total bytes in flight rather than settling near partSize × queueSize × concurrency, you are buffering somewhere and the stream is not really a stream.

Frequently Asked Questions

Is a presigned upload less secure than a proxy?

Not inherently, but it has more to configure correctly. The credential reaches the client, so it must be scoped to one key, one method and a short expiry. A proxy keeps credentials server-side at the cost of bandwidth, memory and latency.

Can I validate file content with presigned uploads?

Yes, but asynchronously. Upload into a quarantine prefix, validate on the storage event, and promote only clean objects. You cannot block a bad upload synchronously the way a proxy can, so your UI must show a “processing” state rather than an immediate success.

When is a proxy actually the right choice?

For small files where an inline verdict matters more than throughput — parsing a CSV, re-encoding an avatar, rewriting a document before it is stored. Below roughly 10 MB the doubled transfer costs milliseconds and buys you a clean synchronous error.

Does a presigned URL survive its signing role’s session?

No. When you sign with temporary STS credentials the URL dies with the session token, so an expiresIn longer than the role’s session duration — one hour by default — silently under-delivers. Only long-lived keys reach the 604800-second ceiling, which is a reason to keep expiries short anyway.

How do I allow a size range rather than an exact size?

A signed ContentLength binds one exact number. For a minimum and maximum, switch to a POST policy with content-length-range, compared in presigned POST vs presigned PUT and implemented in enforcing upload size limits with S3 POST policies.