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
- Node 20.11 or later,
@aws-sdk/client-s3and@aws-sdk/s3-request-presignerat 3.600.0 or later, plus@aws-sdk/lib-storageif you intend to proxy. AWS_REGIONandUPLOAD_BUCKETin the environment; credentials from an instance or task role, never static keys in the signing service.- 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.
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.
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.
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:
ContentLengthin the command is what makes the presigned URL a size cap rather than an open door. Because it is also insignableHeaders, 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: 900is 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 × queueSizeis the proxy’s memory contract. Multiply it by peak concurrency and compare against your container limit before you deploy.leavePartsOnError: falseaborts 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.