Presigned POST
A presigned POST is a browser form upload with a signed policy. The policy is a list of rules. S3 checks every rule against the real request and the real bytes. Use it when S3 itself must enforce a size range, and for forms that send several files at once.
How it works
One file
Several files in one submit
A form with a CV and five photos should not make six presign calls. We do one call that signs every file, and returns one upload ticket that ties the keys to the submit that follows.
Points that matter:
- One bot check, one rate-limit hit per call. Counting per file made a six-file form use six slots. People behind one office or carrier NAT share a per-IP limit, so they ran out fast.
- All or nothing. If one file has a bad type, sign nothing. A half-signed set is a set the client cannot finish, and the files it did upload become orphans.
- The ticket proves the keys. At submit time the API checks the keys came from
this presign, not just that they look right. The ticket is single-use: read
and delete in one step (
MULTIwithGETandDEL). - The ticket fails closed. If Redis is down, refuse the presign. The ticket is an authorization check, not a rate limiter. A rate limiter may fail open. An authorization check must not.
- Return the soonest expiry. The client must finish before the first policy lapses, not the last.
- The ticket lives longer than the policies. It must outlast the last upload plus the time the person takes to press Submit.
How to build it
Server: one policy per file
import { randomUUID } from "node:crypto";
import { S3Client } from "@aws-sdk/client-s3";
import { createPresignedPost } from "@aws-sdk/s3-presigned-post";
const s3 = new S3Client({});
const BUCKET = "example-uploads";
const TTL_SECONDS = 300;
export async function presignPost(prefix: string, ext: string, contentType: string, maxBytes: number) {
const key = `${prefix}/${randomUUID()}/${randomUUID()}.${ext}`;
const { url, fields } = await createPresignedPost(s3, {
Bucket: BUCKET,
Key: key, // exact key, never a prefix
Conditions: [
["content-length-range", 1, maxBytes], // S3 checks the real bytes
{ "Content-Type": contentType }, // exact type, no swapping
],
Fields: { "Content-Type": contentType },
Expires: TTL_SECONDS,
});
return { url, fields, key, expiresAt: new Date(Date.now() + TTL_SECONDS * 1000) };
}
The size the client declares is only a courtesy check, so an obviously huge file
is refused before any signature exists. The binding limit is the
content-length-range in the policy.
We set no server-side encryption field. The bucket's default encryption applies on its own. Requiring an encryption field in the policy would only add one more way for an upload to fail.
Client: build the form
async function postFile(upload: { url: string; fields: Record<string, string> }, file: File) {
const form = new FormData();
for (const [name, value] of Object.entries(upload.fields)) form.append(name, value);
form.append("file", file); // MUST be the last field
const res = await fetch(upload.url, { method: "POST", body: form });
if (!res.ok) {
const xml = await res.text(); // S3 errors are XML, e.g. <Code>EntityTooLarge</Code>
throw new Error(`upload_failed_${res.status}: ${xml.slice(0, 200)}`);
}
}
- The
filefield must come after all the policy fields. S3 ignores fields after the file. - One POST uploads one file. For several files, send several POSTs.
- On success S3 returns
204 No Contentby default. Do not expect JSON.
The policy conditions
| Condition | Example | What it enforces |
|---|---|---|
| Exact key | Key: "scratch/UUID/UUID.pdf" | The upload can land only at this one key. |
| Key prefix | ["starts-with", "$key", "user-uploads/USER_ID/"] | Any key under the prefix. |
| Size range | ["content-length-range", 1, 5242880] | S3 refuses anything outside the range and stores nothing. |
| Exact type | { "Content-Type": "image/jpeg" } | The form must send exactly this type. |
| Type prefix | ["starts-with", "$Content-Type", "image/"] | Any image/... type. Weaker. |
Exact key or prefix?
A policy can use a key prefix plus the ${filename} variable, where S3 puts in
the browser's file name. That lets one policy accept many files. We chose not
to.
- A prefix policy lets one signature upload unlimited objects under that prefix until it expires. One bot-check solve would buy unlimited uploads.
${filename}puts the user's file name in the key. That breaks the key rule in Presigned PUT.
So: one policy per file, each with an exact key, all returned by one API call.
POST or PUT?
| Presigned PUT | Presigned POST | |
|---|---|---|
| Size check by S3 | Only if you sign the exact Content-Length | A range, content-length-range |
| Type check by S3 | Only if you sign Content-Type | Exact or prefix condition |
| Client needs exact size first | Yes | No, any size in the range |
| Request shape | Raw body, simple fetch | multipart/form-data with fields |
| Works for multipart parts | Yes, used for every part | No |
| Good for | Avatars, known-size files, mobile apps | Browser forms, anonymous public forms, several files |
Prefer POST when:
- the uploader is anonymous (a public form), so you want the strongest S3-side limits;
- you want a size range, not an exact size;
- one call hands out several upload slots.
Prefer PUT when the client knows the exact size, or for multipart parts.
Rules
One policy per file, with an exact key. Never a starts-with $key prefix policy
for untrusted uploaders. A prefix lets one signature write without limit.
Every policy has a content-length-range. It is the only size limit S3 enforces
on a POST.
For a multi-file form: one presign call, one bot check, one rate-limit hit, and an upload ticket bound to the minted keys. Never a bot check inside a per-file loop.
Bucket CORS must allow POST from the web origin, in every environment. A POST
from a browser is cross-origin, and S3 answers the preflight from its CORS rules.
Mistakes we made
- A public form cost eight bot checks and lost submissions. Each of seven files did its own bot check and presign, then the submit did an eighth. When the bot check turned interactive, a challenged visitor would have had to clear it eight times. One run in production with seven files: six uploaded, one never did, and the submit never ran. The person saw "check your connection" and the submission was gone. Six orphan files stayed in the bucket. The fix was the batch flow above.
- A quick fix shipped before the real one. Before the batch flow was out, a client-only fix retried each upload on the same URL and stopped at the first hard failure. That stopped the data loss. It did not make the form fast. Ship the stop-gap, but track the real fix.
- The rate limit was charged per file. 30 presigns an hour with 7 files per form meant about 4 registrations an hour per IP, shared by everyone behind the same NAT. Charge the limit once per call.
- Bucket CORS and lifecycle rules existed only in the live bucket. They were not in Terraform. A plain apply would have deleted both and broken every upload at the preflight.
Checklist
- Exact key per policy. No
${filename}, no key prefix. -
content-length-rangeand exactContent-Typein every policy. - Short expiry, a few minutes.
- Batch: validate all, sign all, one ticket, soonest expiry returned.
- The ticket is single-use and fails closed.
- Rate limit and bot check run once per call.
- The client appends the file last and handles XML errors.
- Bucket CORS allows
POSTfrom the form's origin. - A lifecycle rule expires the scratch prefix for files never submitted.
- Submit checks the keys against the ticket, then
HeadObjecton each.