Presigned POST vs Presigned PUT for Browser Uploads

Use presigned POST when an untrusted client must be constrained by rules S3 enforces on your behalf — a maximum byte count, a key prefix, an exact content type — and presigned PUT when your own API already decided everything and you just need the cheapest possible transport.

Both modes belong to S3 presigned URL workflows inside backend validation and cloud storage architecture, and both hand the browser a short-lived credential rather than routing bytes through your servers. The difference is not security in the abstract: it is who checks the request. With PUT, only the values you folded into the signature are checked. With POST, a base64 policy document travels with the form and S3 evaluates every condition in it before the object is written.

When to use this approach

  • You are exposing uploads to clients you do not control — a public form, a mobile app you cannot force-update, a partner integration — and you need a size ceiling that cannot be bypassed by patching the JavaScript.
  • You want the storage layer, not your API, to be the enforcement point, so a compromised or buggy client cannot write outside its prefix.
  • If instead your API already validates size and MIME type before signing, and the caller is your own first-party app, PUT is simpler and you should read presigned URL vs server proxy trade-offs for the broader architectural choice.

Prerequisites

  1. Node 20+ and @aws-sdk/client-s3@^3.600, plus @aws-sdk/s3-request-presigner for PUT and @aws-sdk/s3-presigned-post for POST.
  2. An IAM role for the signing service with s3:PutObject on arn:aws:s3:::your-bucket/incoming/* and nothing wider — signing never checks your permissions, so the credential’s own scope is the last line of defence.
  3. Bucket CORS allowing the method you choose from your app origin, as covered in fixing CORS preflight errors on S3 uploads.
  4. AWS_REGION and S3_BUCKET in the signing service’s environment.

What the browser actually receives

A presigned PUT is a single string. Every constraint lives in the query parameters, and X-Amz-SignedHeaders names the headers the browser must reproduce byte-for-byte. A presigned POST is a URL plus a bag of form fields, one of which is a base64-encoded policy and another the signature over it.

Anatomy of a presigned PUT URL versus a presigned POST payload Left panel lists the query parameters of a presigned PUT URL; right panel lists the form fields returned by createPresignedPost, including the policy document and the file field last. What your endpoint hands to the browser Presigned PUT — one URL https://bkt.s3.eu-west-1.../incoming/a.bin ?X-Amz-Algorithm=AWS4-HMAC-SHA256 &X-Amz-Credential=AKIA.../s3/aws4_request &X-Amz-Date=20260726T101500Z &X-Amz-Expires=900 &X-Amz-SignedHeaders=content-type;host &X-Amz-Signature=8f2c1b... Checked: only the signed values Body: the raw file bytes Presigned POST — url + fields key: incoming/user-42/a.bin Content-Type: image/png success_action_status: 201 Policy: eyJleHBpcmF0aW9uIjoiMjAy... X-Amz-Credential / -Date / -Algorithm X-Amz-Signature: 1a9d4e... file: the bytes — appended LAST Checked: every policy condition Body: multipart/form-data
PUT hides its constraints in the signature; POST ships them as a policy document the client can read but not alter.

The policy is not secret — anyone can base64-decode it and read the conditions. That is fine. Tampering with a condition invalidates X-Amz-Signature, and the client has no signing key.

The two modes side by side

Factor Presigned PUT Presigned POST
SDK entry point getSignedUrl(client, new PutObjectCommand(...)) createPresignedPost(client, { Conditions, Fields })
Returned to the client One URL string { url, fields }
Request body Raw bytes multipart/form-data
Max size enforced by S3 Only as an exact signed Content-Length ["content-length-range", min, max]
Key prefix enforced No — the key is fixed at signing ["starts-with", "$key", "incoming/"]
Content type enforced Yes, if content-type is signed Yes, exact or starts-with
ACL / storage class fields Only if signed into the command Any POST form field the policy allows
Success response 200 OK, ETag header 204 No Content, or 201 + XML
Works in a plain HTML form No Yes
Usable for multipart UploadPart Yes No
Wire overhead None ~600–900 bytes of fields and boundaries
Client code Three lines Build a FormData, order matters

The rule of thumb sits in the two middle rows. If your answer to “what stops a modified client from uploading 4 GB?” has to be something other than “my API asked nicely”, you need POST.

Implementation

One module, both signers. The PUT path bakes the key and content type into the signature; the POST path publishes them as conditions instead.

// npm i @aws-sdk/client-s3 @aws-sdk/s3-request-presigner @aws-sdk/s3-presigned-post
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { createPresignedPost } from "@aws-sdk/s3-presigned-post";
import { randomUUID } from "node:crypto";

const s3 = new S3Client({ region: process.env.AWS_REGION });
const BUCKET = process.env.S3_BUCKET as string;
const MAX_BYTES = 10 * 1024 * 1024; // 10 MB ceiling for both modes
const TTL_SECONDS = 900;

/** PUT: your API has already validated. The signature is the only contract. */
export async function signPut(userId: string, contentType: string) {
  const key = `incoming/${userId}/${randomUUID()}.bin`;
  const url = await getSignedUrl(
    s3,
    new PutObjectCommand({ Bucket: BUCKET, Key: key, ContentType: contentType }),
    {
      expiresIn: TTL_SECONDS,
      // Without this, content-type is hoisted into the query string and
      // becomes advisory: the browser can send any type it likes.
      signableHeaders: new Set(["content-type", "host"]),
    },
  );
  return { mode: "PUT" as const, key, url };
}

/** POST: S3 evaluates the policy before a single byte is committed. */
export async function signPost(userId: string, contentType: string) {
  const prefix = `incoming/${userId}/`;
  const { url, fields } = await createPresignedPost(s3, {
    Bucket: BUCKET,
    Key: `${prefix}${randomUUID()}.bin`,
    Expires: TTL_SECONDS,
    Conditions: [
      ["content-length-range", 1, MAX_BYTES], // rejects empty and oversize
      ["starts-with", "$key", prefix],        // the client cannot escape its folder
      { "Content-Type": contentType },        // exact match, case-sensitive
      { success_action_status: "201" },       // return XML instead of a bare 204
      // { acl: "private" },                  // ONLY if the bucket still allows ACLs
    ],
    Fields: {
      "Content-Type": contentType,
      success_action_status: "201",
    },
  });
  return { mode: "POST" as const, prefix, url, fields };
}

Line-by-line walkthrough

  • signableHeaders: new Set(["content-type", "host"]) is the parameter people forget. By default the presigner hoists unsigned headers into the query string, so Content-Type becomes a hint rather than a constraint and a client can PUT a .exe under an image/png key. Adding it to signableHeaders puts content-type in X-Amz-SignedHeaders, and a mismatch then costs the client a 403.
  • Conditions versus Fields. Fields are the key/value pairs the SDK returns for the form; Conditions are what S3 checks. Every field the browser submits must be covered by a condition, so list exact-match values in both. A duplicated identical condition is harmless.
  • ["content-length-range", 1, MAX_BYTES] is the whole reason POST exists. The bounds are inclusive byte counts. A min of 1 rejects zero-byte submissions, which are otherwise a common source of orphaned rows in your metadata table.
  • ["starts-with", "$key", prefix] lets the client choose the tail of the key while pinning the folder. Pair it with Key: "incoming/user-42/${filename}" when you want the browser’s own filename in the object key; the SDK converts the ${filename} suffix into a starts-with condition automatically. Never do this without sanitising the name downstream — ../ in a key is legal S3 and will surprise anything that later writes it to disk.
  • { "Content-Type": contentType } is an exact, case-sensitive match. To accept a family instead, swap it for ["starts-with", "$Content-Type", "image/"].
  • success_action_status: "201" changes the response from an empty 204 to a 201 carrying an XML body with Location, Bucket, Key and ETag. Without it, the client learns nothing about what it just created.
  • acl is commented out deliberately. Buckets created since April 2023 default to ObjectOwnership: BucketOwnerEnforced, which disables ACLs entirely; sending the field then fails the whole upload. See the gotchas below.

Sending each one from the browser

The PUT client is barely worth a function.

export async function uploadWithPut(file: File, url: string): Promise<string | null> {
  const res = await fetch(url, {
    method: "PUT",
    headers: { "Content-Type": file.type || "application/octet-stream" },
    body: file, // streamed by the browser, never buffered
  });
  if (!res.ok) throw new Error(`PUT failed ${res.status}: ${await res.text()}`);
  return res.headers.get("ETag"); // null unless CORS exposes it
}

The POST client has exactly two rules: append the signed fields first, and append file last.

export async function uploadWithPost(
  file: File,
  url: string,
  fields: Record<string, string>,
): Promise<string> {
  const form = new FormData();
  for (const [name, value] of Object.entries(fields)) form.append(name, value);
  form.append("file", file, file.name); // MUST be the final field

  // Do NOT set a Content-Type header here — the browser must generate the
  // multipart boundary itself, and overriding it strips the boundary.
  const res = await fetch(url, { method: "POST", body: form });
  if (res.status !== 201) throw new Error(`POST failed ${res.status}: ${await res.text()}`);

  const xml = await res.text();
  return xml.match(/<Key>([^<]+)<\/Key>/)?.[1] ?? "";
}

S3 streams the multipart body and stops parsing at the file part, which is why the ordering rule exists — any field after file is never read, so its condition can never be satisfied. If you want the byte-level picture of what FormData is producing here, implementing multipart/form-data in vanilla JavaScript walks the boundaries and headers.

Where the constraint is actually enforced

Consider a client that submits 12 MB against a 10 MB limit. Under PUT with a size checked only by your API, S3 has no opinion: the object lands, you pay for the transfer and the storage, and some asynchronous job has to notice and delete it. Under POST, S3 aborts the request itself.

Rejection point for an oversize upload under PUT and under POST With presigned PUT the oversize object is stored and later deleted by a scanner; with a presigned POST content-length-range condition S3 returns EntityTooLarge and nothing is stored. A 12 MB body meets a 10 MB limit Presigned PUT Browser PUT 12 MB S3 returns 200 12 MB now stored Your job deletes it transfer + storage paid Presigned POST with content-length-range Browser POST 12 MB S3 returns 400 EntityTooLarge Nothing was written no cleanup, no bill The policy moves rejection out of your pipeline and onto the S3 edge.
A content-length-range condition turns a cleanup problem into a 400 response, and the failure is synchronous for the user.

Be honest about what this saves: S3 has to receive part of the body before it can reject it, so the user’s upstream bandwidth is still spent. What you avoid is the storage write, the event notification, the scanner invocation and the delete — plus the class of bug where the cleanup job is broken and nobody notices for a month.

What a POST policy cannot check

The policy is a string matcher over form fields. It never looks at the bytes. It cannot tell you that a file declaring image/png is actually a 400 MB zip, that a PNG decodes to 30,000 × 30,000 pixels, or that the payload carries a macro. Those remain post-upload jobs, exactly as with PUT — see validating file signatures with libmagic in Node.js for the byte-level check and setting up S3 lifecycle rules for temporary uploads for expiring whatever never gets promoted. POST narrows the blast radius; it does not replace validation.

There is also one structural limit: presigned POST cannot drive the multipart upload API. UploadPart is a PUT, so any file large enough to need parts — the threshold discussed in multipart vs single-PUT for files under 100 MB — has to use presigned PUT per part, with the size ceiling enforced by how many part URLs you are willing to issue.

Making a PUT enforce a size after all

If you are on PUT and want a real ceiling without switching to POST, sign ContentLength. The browser sets Content-Length automatically from the body and cannot override it — it is a forbidden header name — so any body of a different length fails the signature check.

export async function signPutExactSize(userId: string, contentType: string, bytes: number) {
  if (!Number.isInteger(bytes) || bytes <= 0 || bytes > MAX_BYTES) {
    throw new Error(`declared size ${bytes} outside 1..${MAX_BYTES}`);
  }
  const key = `incoming/${userId}/${randomUUID()}.bin`;
  return getSignedUrl(
    s3,
    new PutObjectCommand({
      Bucket: BUCKET,
      Key: key,
      ContentType: contentType,
      ContentLength: bytes,
    }),
    {
      expiresIn: TTL_SECONDS,
      signableHeaders: new Set(["content-type", "content-length", "host"]),
    },
  );
}

The client sends file.size first, your API refuses anything above the ceiling, and S3 refuses anything that does not match the number it signed. It is exact-match only — no ranges — so it costs you a round trip before the upload and breaks if any proxy re-encodes the body. That is the honest trade: PUT can pin one number, POST can express a range.

Choosing between them

Decision tree for choosing presigned PUT or presigned POST If S3 must enforce the limit itself, choose presigned POST; otherwise presigned PUT, with the supporting conditions for each listed below. Which signing mode? Must S3 enforce the rule if the client is modified? no yes Presigned PUT your API is the gate Presigned POST the policy is the gate first-party trusted app server picks the whole key parts of a multipart upload public or partner clients content-length-range needed client supplies the filename
One question decides it: whether a modified client should still be constrained.

In practice most media products run both. Signed-in users of the first-party web app get PUT URLs, because the API already knows the file and the transport should be as thin as possible. Anonymous submission forms and partner endpoints get POST policies with a tight content-length-range, because the client is a stranger. Either way the destination is a quarantine prefix, and promotion happens after validation — the pattern described across direct-to-cloud upload patterns.

Configuration gotchas

The file field is not last. S3 responds 400 with <Code>InvalidArgument</Code><Message>Bucket POST must contain a field named 'file'. If it is specified, please check the order of the fields.</Message>. S3 stops parsing at file, so anything appended afterwards is invisible. Append every signed field before form.append("file", ...).

You added a field the policy never mentioned. The response is 403 with <Code>AccessDenied</Code><Message>Invalid according to Policy: Extra input fields: x-amz-meta-owner</Message>. Every non-signature field must be covered by a condition; add { "x-amz-meta-owner": value } to both Fields and Conditions, or drop it from the form.

A value differs from the condition. You get 403 with <Code>AccessDenied</Code><Message>Invalid according to Policy: Policy Condition failed: ["eq", "$Content-Type", "image/png"]</Message>. The comparison is byte-exact: image/PNG fails, and so does the image/jpeg a browser reports for a file the server called image/jpg. Normalise the type on the server before signing.

You sent acl to a modern bucket. 400 with <Code>AccessControlListNotSupported</Code><Message>The bucket does not allow ACLs</Message>. Buckets default to ObjectOwnership: BucketOwnerEnforced, which rejects any ACL field outright. Remove acl from Fields and Conditions and control access with a bucket policy instead.

The browser set the Content-Type for you. If your client passes headers: { "Content-Type": "multipart/form-data" }, the boundary parameter is lost and S3 answers 400 with <Code>MalformedPOSTRequest</Code><Message>The body of your POST request is not well-formed multipart/form-data.</Message>. Never set that header manually when the body is a FormData.

Verification

Prove the ceiling is real rather than assuming it. This script asks your signing endpoint for a POST policy, then deliberately submits 11 MB against the 10 MB condition.

// verify-post-policy.mjs — Node 20+, no dependencies
const signed = await fetch("http://localhost:3000/sign-post", { method: "POST" });
const { url, fields } = await signed.json();

const form = new FormData();
for (const [name, value] of Object.entries(fields)) form.append(name, value);
form.append("file", new Blob([new Uint8Array(11 * 1024 * 1024)]), "oversize.bin");

const res = await fetch(url, { method: "POST", body: form });
console.log(res.status, (await res.text()).slice(0, 240));
// Expected: 400 <?xml ...><Error><Code>EntityTooLarge</Code>
//           <Message>Your proposed upload exceeds the maximum allowed size</Message>

A 201 here means your policy is not doing what you think — check that content-length-range is in Conditions and not accidentally in Fields. For the PUT side, confirm the signed content type is binding:

# Signed as image/png; send text/plain and the signature must fail.
curl -s -o /dev/null -w '%{http_code}\n' -X PUT \
  -H "Content-Type: text/plain" --data-binary @probe.png "$PUT_URL"
# Expected: 403 (SignatureDoesNotMatch). A 200 means content-type was not signed.

Frequently Asked Questions

Can I use presigned POST without any JavaScript?

Yes — that is one of its quiet advantages. Render the returned fields as <input type="hidden"> elements inside a <form method="post" enctype="multipart/form-data"> with the file input last, and add a success_action_redirect condition so S3 bounces the browser back to your app with bucket, key and etag as query parameters. Presigned PUT cannot do this, because HTML forms only speak GET and POST.

Why does my successful POST return 204 with an empty body?

That is the default. S3 answers 204 No Content unless the form carries success_action_status set to 200 or 201, or a success_action_redirect URL. Use 201 and you get an XML body with Location, Bucket, Key and ETag, which saves the client a HEAD request to learn what it just wrote.

How long should the policy or URL live?

Fifteen minutes is a sensible default for both, and the expiry only has to be in the future when S3 receives the request — a slow upload that starts at minute fourteen is not cut off mid-stream. Longer TTLs mostly widen the window in which a leaked URL is useful, so extend them only for genuinely slow clients.

Do the two modes need different CORS rules?

Yes. AllowedMethods must list POST rather than PUT, and preflight is triggered by different headers — a FormData POST usually skips preflight entirely because it is a simple request, whereas a PUT with an explicit Content-Type always triggers one. If you support both, list both methods and keep ExposeHeaders: ["ETag"] for the PUT path.