Skip to main content

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 (MULTI with GET and DEL).
  • 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 file field 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 Content by default. Do not expect JSON.

The policy conditions​

ConditionExampleWhat it enforces
Exact keyKey: "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 PUTPresigned POST
Size check by S3Only if you sign the exact Content-LengthA range, content-length-range
Type check by S3Only if you sign Content-TypeExact or prefix condition
Client needs exact size firstYesNo, any size in the range
Request shapeRaw body, simple fetchmultipart/form-data with fields
Works for multipart partsYes, used for every partNo
Good forAvatars, known-size files, mobile appsBrowser 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​

Rule

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.

Rule

Every policy has a content-length-range. It is the only size limit S3 enforces on a POST.

Rule

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.

Rule

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-range and exact Content-Type in 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 POST from the form's origin.
  • A lifecycle rule expires the scratch prefix for files never submitted.
  • Submit checks the keys against the ticket, then HeadObject on each.