Upload to S3 From the Browser With a Presigned POST (AWS SDK v3)

A laptop on a wooden desk showing a file upload progress screen

Photo by Devin Pickell on Unsplash

An S3 presigned POST with AWS SDK v3 comes from createPresignedPost in @aws-sdk/s3-presigned-post. Your server signs a policy with conditions such as content-length-range, a starts-with rule for $Content-Type and an exact key, and returns a url plus form fields. The browser posts those fields and the file, last, as multipart/form-data, and S3 enforces every condition.

This guide is for Node.js and TypeScript developers who let users upload images, documents or exports directly to S3 without streaming them through an API server. You’ll get the server function, the decoded policy it produces, the browser upload code, the bucket CORS rule, the IAM permission for the signer, and fixes for the errors you’re likely to hit.

Every sample was type-checked with strict tsc against @aws-sdk/client-s3 and @aws-sdk/s3-presigned-post 3.1142.0 in September 2026. The policy below was generated locally and decoded, and the signature was checked independently against the Signature Version 4 key derivation.

Presigned POST or presigned PUT: which should you use?

Both let a client without AWS credentials upload one object. The difference is how much the signature controls:

Presigned POST Presigned PUT URL
SDK v3 call createPresignedPost (@aws-sdk/s3-presigned-post) getSignedUrl with PutObjectCommand (@aws-sdk/s3-request-presigner)
File size limit Yes: content-length-range sets a minimum and maximum No range; the URL can’t express “up to 10 MiB”
Content type Exact match or starts-with (for example any image/) Only an exact value, if you sign it
Key Exact match or prefix Fixed in the URL
Request shape HTML form or FormData, multipart/form-data Raw body with PUT

Size enforcement is the usual reason to pick POST: with a PUT URL, a user can upload a 4 GB file where you expected a profile photo, and you only find out after you’ve paid to store it. If you don’t need limits, the simpler route is to create a presigned S3 upload URL with a PUT request. For the reverse direction, see creating a presigned S3 download URL with SDK v3.

Prerequisites

How to create an S3 presigned POST with AWS SDK v3, step by step

  1. Authenticate the user firstOnly hand out upload forms to signed-in users, and derive the key from who they are.
  2. Pick the key on the serverA random UUID under a per-user prefix avoids collisions and stops users overwriting each other’s files.
  3. Add conditionscontent-length-range for size, starts-with for the content type, and any fixed fields such as success_action_status.
  4. Keep it short-livedExpires is in seconds and defaults to 3,600. A few minutes is enough for a user who just picked a file.
  5. Post from the browserAppend every returned field, then your own fields, then the file as the last field.

Example: the server function

create-upload-form.ts

// create-upload-form.ts: server-side. Returns a presigned POST that lets a browser upload one image
// of up to 10 MiB to uploads/<userId>/<uuid> within the next 5 minutes.
import { randomUUID } from "node:crypto";
import { S3Client } from "@aws-sdk/client-s3";
import { createPresignedPost, type PresignedPost } from "@aws-sdk/s3-presigned-post";

const s3 = new S3Client({ region: process.env.AWS_REGION ?? "us-east-1" });
const BUCKET = process.env.UPLOAD_BUCKET ?? "amzn-s3-demo-bucket";
const MAX_BYTES = 10 * 1024 * 1024; // 10 MiB

export async function createUploadForm(userId: string): Promise<PresignedPost & { key: string }> {
  if (!/^[A-Za-z0-9_-]{1,64}$/.test(userId)) throw new Error("invalid user id");
  const key = `uploads/${userId}/${randomUUID()}`; // the server picks the key, never the browser

  const post = await createPresignedPost(s3, {
    Bucket: BUCKET,
    Key: key, // added to the policy as an exact-match { key } condition
    Conditions: [
      ["content-length-range", 1, MAX_BYTES], // S3 rejects empty files and files over 10 MiB
      ["starts-with", "$Content-Type", "image/"], // the form must carry an image/* Content-Type field
    ],
    Fields: {
      success_action_status: "201", // every Fields entry is also added as an exact-match condition
    },
    Expires: 300, // seconds until the policy expires; the default is 3600
  });
  return { ...post, key };
}

Two behaviors of createPresignedPost matter here. Every entry in Fields is copied into the policy as an exact-match condition, so the browser can’t change success_action_status. And the Key becomes an exact key condition, unless it ends with ${filename}, in which case the library adds a starts-with condition on the prefix instead. Avoid ${filename} for user uploads: it lets the browser choose the object name inside the prefix.

What’s inside the policy document?

The Policy field is a base64-encoded JSON document with an expiration and a list of conditions, and X-Amz-Signature is an HMAC-SHA256 of that string with your Signature Version 4 signing key. Decoding it is the fastest way to check what you’re actually enforcing:

print-policy.ts

// print-policy.ts: generate a presigned POST locally and decode its policy document.
// Nothing is sent to AWS; the signature is computed from the credentials the SDK resolves.
import { createUploadForm } from "./create-upload-form.js";

const { url, fields, key } = await createUploadForm("user-42");
console.log("url:", url);
console.log("key:", key);
console.log("fields:", Object.keys(fields).join(", "));
const policy = JSON.parse(Buffer.from(fields.Policy, "base64").toString("utf8")) as {
  expiration: string;
  conditions: unknown[];
};
console.log(JSON.stringify(policy, null, 2));
Output

url: https://amzn-s3-demo-bucket.s3.us-east-1.amazonaws.com/
key: uploads/user-42/7bbafa93-009c-48f8-a494-243e441d6bc9
fields: success_action_status, bucket, X-Amz-Algorithm, X-Amz-Credential, X-Amz-Date, key, Policy, X-Amz-Signature
{
  "expiration": "2026-09-29T04:37:09Z",
  "conditions": [
    ["content-length-range", 1, 10485760],
    ["starts-with", "$Content-Type", "image/"],
    {"success_action_status": "201"},
    {"bucket": "amzn-s3-demo-bucket"},
    {"X-Amz-Algorithm": "AWS4-HMAC-SHA256"},
    {"X-Amz-Credential": "AKIAIOSFODNN7EXAMPLE/20260929/us-east-1/s3/aws4_request"},
    {"X-Amz-Date": "20260929T043209Z"},
    {"key": "uploads/user-42/7bbafa93-009c-48f8-a494-243e441d6bc9"}
  ]
}

This run used the documentation’s example access key, so it made no request to AWS. The expiration is 5 minutes after signing. The library added the bucket, algorithm, credential, date and key conditions itself. When the credentials are temporary (a Lambda role, SSO or an assumed role), it also adds an X-Amz-Security-Token field and condition, which we confirmed by signing with a session token.

S3 requires that every field in the form, except x-amz-signature, file, policy and fields starting with x-ignore-, appears in the conditions. That’s why the browser’s Content-Type field needs the starts-with rule. For Content-Type, a comma-separated value is treated as a list and every entry must pass, so image/png,text/html fails the image/ condition.

Example: upload the file from the browser

upload.ts

// upload.ts: browser-side. Posts a File to S3 with the url and fields returned by your backend.
export interface UploadForm {
  url: string;
  fields: Record<string, string>;
}

export async function uploadToS3(form: UploadForm, file: File): Promise<string> {
  const body = new FormData();
  for (const [name, value] of Object.entries(form.fields)) body.append(name, value);
  body.append("Content-Type", file.type); // checked by the starts-with $Content-Type condition
  body.append("file", file); // S3 requires the file to be the last field in the form

  // Don't set a Content-Type header yourself: the browser adds multipart/form-data with the boundary.
  const res = await fetch(form.url, { method: "POST", body });
  const text = await res.text();
  if (res.status !== 201) {
    const code = /<Code>(.*?)<\/Code>/.exec(text)?.[1] ?? `HTTP ${res.status}`;
    throw new Error(`S3 rejected the upload: ${code}`);
  }
  return /<Location>(.*?)<\/Location>/.exec(text)?.[1] ?? form.url; // 201 responses include the object URL
}

We ran this function in Node.js 24 against a local server that recorded the multipart fields: the returned fields came first, then Content-Type, then file, and fetch set the multipart/form-data header with its boundary. With success_action_status set to 201, S3 answers with an XML document that includes Location, Bucket, Key and ETag; with 200 or the default 204 the body is empty. A classic <form method="post" enctype="multipart/form-data"> with hidden inputs works the same way if you don’t want JavaScript.

Which CORS rule does the bucket need?

The upload is a cross-origin request from your app to the bucket’s endpoint, so the bucket needs a CORS rule that allows POST from your origin:

cors.json

{
  "CORSRules": [
    {
      "AllowedOrigins": ["https://app.example.com"],
      "AllowedMethods": ["POST"],
      "ExposeHeaders": ["ETag", "Location"],
      "MaxAgeSeconds": 3000
    }
  ]
}
Terminal

aws s3api put-bucket-cors --bucket amzn-s3-demo-bucket --cors-configuration file://cors.json

A fetch POST with a FormData body and no custom headers is a CORS simple request, so the browser sends no preflight. MDN’s guide to cross-origin resource sharing lists the conditions; one of them is that an XMLHttpRequest has no listeners on upload, so adding upload progress events triggers a preflight. If you add custom headers or progress events, list the headers in AllowedHeaders. Name your real origins rather than *. CORS only decides which websites can read S3’s responses; the signed policy is what controls the upload, and there’s no reason to let every site read the results. The script to find S3 buckets with wildcard CORS allowed origins shows which buckets already allow *.

Which IAM permissions does the signer need?

A presigned POST carries the permissions of the credentials that signed it. The server’s role needs s3:PutObject on the prefix, and nothing else for this flow:

upload-signer-policy.json

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "SignUploadsUnderPrefix",
      "Effect": "Allow",
      "Action": "s3:PutObject",
      "Resource": "arn:aws:s3:::amzn-s3-demo-bucket/uploads/*"
    }
  ]
}

Add s3:PutObjectTagging if you include a tagging field, and kms:GenerateDataKey on the key if the bucket encrypts with SSE-KMS. The IAM policy generator for TypeScript SDK code can draft the policy from your server code, and finding the IAM actions used in AWS SDK JavaScript code explains how commands map to actions.

Troubleshooting and common mistakes

  • The browser console reports a CORS error. The bucket has no rule for your origin or for POST. The upload may still have reached S3; the browser only blocks your code from reading the response.
  • EntityTooLarge or EntityTooSmall. The S3 error reference describes these as an upload that exceeds the maximum or is below the minimum allowed size. With a presigned POST, check the file against your content-length-range in the browser before posting.
  • AccessDenied or InvalidPolicyDocument. S3 returns an XML body whose Code and Message say what failed. The common causes: the policy has expired, a form field isn’t covered by a condition (often a Content-Type field with no matching condition), a value doesn’t match, or the signer lacks s3:PutObject. Decode the policy as shown above and compare it with what the browser sends, then see troubleshooting AWS IAM access denied errors for the permission side.
  • ExpiredToken. The form was signed with temporary credentials that expired. For presigned URLs, S3 documents that the URL stops working when those credentials expire, even if the configured expiry is later, and the presigned POST carries the same session token. Sign forms on demand instead of caching them.
  • IncorrectNumberOfFilesInPostRequest. POST accepts exactly one file per request. Loop over files and request one form per file.
  • Fields after the file. S3 requires the file to be the last field in the form. Append it last.
  • Setting the Content-Type header on fetch. Leave it out; a hand-written header lacks the multipart boundary the browser generates.

What are the limits of a presigned POST?

  • One request, one file. There’s no multipart upload or resume. For very large files, use presigned multipart uploads or upload from the server; uploading large files and streams to S3 with SDK v3 covers the server side.
  • No content inspection. starts-with $Content-Type checks the field the browser sends, not the bytes. Validate files after upload, for example with an S3 event and a Lambda function.
  • Lifetime tied to credentials. Temporary credentials cut the policy short, as described above.
  • Cost. Each upload is a request billed like a PUT; how S3 request pricing works explains the per-request charges.

After upload, you can check the object from the server with a TypeScript check that an S3 object exists, or label it with S3 object tagging in AWS SDK v3. Migrating an older s3.createPresignedPost call from SDK v2? The free AWS SDK v2 to v3 converter drafts the change, and migrating a Node.js app from AWS SDK v2 to v3 covers the rest.

Frequently asked questions

How do I limit the file size of an S3 presigned upload?

Use a presigned POST with a ["content-length-range", min, max] condition. S3 rejects uploads outside the range. A presigned PUT URL has no equivalent range condition.

How long is a presigned POST valid in AWS SDK v3?

Expires is in seconds and defaults to 3,600 (one hour). It can end sooner if the signing credentials are temporary and expire first.

Why does S3 say my POST form has a field not in the policy?

Every form field except x-amz-signature, file, policy and x-ignore- fields must match a condition. Add a condition for the field or remove it from the form.

Does the browser need AWS credentials for a presigned POST?

No. The server signs the policy with its own credentials; the browser only sends the returned fields and the file.

Related guides

Ask your AWS account in plain English

Your first 15 runs are free, with no OpenAI key needed.

npx chatwithcloud