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
- Node 20+ and
@aws-sdk/client-s3@^3.600, plus@aws-sdk/s3-request-presignerfor PUT and@aws-sdk/s3-presigned-postfor POST. - An IAM role for the signing service with
s3:PutObjectonarn: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. - Bucket CORS allowing the method you choose from your app origin, as covered in fixing CORS preflight errors on S3 uploads.
AWS_REGIONandS3_BUCKETin 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.
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, soContent-Typebecomes a hint rather than a constraint and a client can PUT a.exeunder animage/pngkey. Adding it tosignableHeadersputscontent-typeinX-Amz-SignedHeaders, and a mismatch then costs the client a403.ConditionsversusFields.Fieldsare the key/value pairs the SDK returns for the form;Conditionsare 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. Aminof1rejects 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 withKey: "incoming/user-42/${filename}"when you want the browser’s own filename in the object key; the SDK converts the${filename}suffix into astarts-withcondition 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 empty204to a201carrying an XML body withLocation,Bucket,KeyandETag. Without it, the client learns nothing about what it just created.aclis commented out deliberately. Buckets created since April 2023 default toObjectOwnership: 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.
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
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.