·Clement Baranger

Presigned POST: cap size and type before the upload starts

A presigned PUT signs a URL, not a byte count. A POST policy adds a size range and a content-type condition that S3 enforces while the body arrives.

Every "upload directly to S3" feature starts the same way: the server hands the browser a presigned URL, the browser PUTs the file, the object appears. It ships in ten minutes, and quietly grants anyone holding that link the right to store four gigabytes of anything in your bucket. The fix is a different primitive: a presigned POST, where the permission is a document stating the size and type it will accept, signed before a single byte moves.

The short version

  • A presigned URL signs one request — a host, a path, a verb. It never says how many bytes may follow.
  • A presigned POST signs a policy: a short JSON document listing conditions the upload must satisfy, such as a byte range and a content-type prefix.
  • S3 checks the policy first, then counts the body as it arrives. An oversized file stops at 400 EntityTooLarge and nothing is stored.
  • The limit lives in the signature, so it holds even if the browser is modified or replaced by curl.
  • The hours are in the audit, not the fix. The policy is ten lines; finding the stray objects that got past the old endpoint means listing the bucket and sorting by size.

What actually happens

A presigned PUT is an HMAC over a canonical request: method, host, path, and the headers you chose to sign. The body is not part of it. The signature says "PUT to this key is allowed until this time", and the client decides what to send. You can sign Content-Type so a different type fails with SignatureDoesNotMatch, which is a type check and not a size check — the length is still whatever the client announces.

A presigned POST moves the constraints inside the signature. You write a policy document, base64-encode it, and HMAC-SHA256 the base64 with the signing key derived from your secret, the date, the region and s3. That signature goes back to the client as a form field, with the encoded policy beside it. The browser then builds a multipart/form-data body: the auth fields, whatever metadata you allowed, and the file.

The policy has two parts. expiration is an ISO8601 timestamp. conditions is an array where each entry is an exact match ({"bucket": "uploads-prod"}), a prefix (["starts-with", "$key", "incoming/"]), or the one numeric range the format supports: ["content-length-range", 1, 10485760], inclusive at both ends, in bytes. The array can also carry Content-Type, Cache-Control, Content-Encoding, Expires, success_action_status, x-amz-meta-* and the storage class.

Flow of a presigned POST: the policy document is base64-encoded and HMAC-signed on your server, the browser posts the form fields with the file last, and S3 walks four checks — signature, declared fields, condition matches, then the byte count — before storing the object

One detail catches people who read the policy as plain string matching: in a starts-with condition on $Content-Type, comma-separated values are treated as a list and every item has to pass. So ["starts-with", "$Content-Type", "image/"] accepts image/jpg,image/png,image/gif and rejects image/jpg,text/plain. Other fields are compared as strings, commas included.

The key field supports ${filename}, expanded from the client's filename before any condition is evaluated — which is why the condition tests the prefix incoming/ and not incoming/${filename}. AWS is explicit about that ordering.

Where it bites

Four checks run in a fixed order, each with its own error. The status codes overlap, so read the body.

ResponseError codeWhat it means
400EntityTooLargeOver the maximum. The body carries <ProposedSize> and <MaxSizeAllowed>
400EntityTooSmallUnder the minimum. Carries <MinSizeAllowed>
403 AccessDeniedInvalid according to Policy: Policy Condition failed: ["starts-with", "$Content-Type", "image/"]A condition did not match; the failing condition is quoted back
403 AccessDeniedInvalid according to Policy: Extra input fields: content-encodingThe form sent a field the policy never declared
403 AccessDeniedInvalid according to Policy: Policy expired.The expiration timestamp has passed
400InvalidPolicyDocument"The content of the form does not meet the conditions specified in the policy document"
400MaxPostPreDataLengthExceededErrorEverything before the file part exceeded 20 KB
400IncorrectNumberOfFilesInPostRequestPOST takes exactly one file per request

The limit is enforced while streaming. S3 counts the file part as the body arrives, stops reading the moment the counter passes max, and stores nothing partial — a documented property of the operation, not something to hope for.

The form has a size of its own. Form data and boundaries, excluding the file contents, cannot exceed 20 KB. A normal policy is under 1 KB, so you only meet this by stuffing base64 thumbnails into x-amz-meta-*; that metadata belongs in your database.

The file must be the last field. AWS says it twice: "make sure that the file field is the last field in the form". Put key after it and the key arrives missing.

No size cap on a PUT. There is no content-length-range equivalent in a PUT signature; the nearest thing is signing a Content-Length header, which browsers do not let JavaScript set. That gap is what keeps the insecure pattern alive.

Provider deviation is real, and one is fatal. Cloudflare R2 documents that POST — form uploads via HTML forms — "is not currently supported", and answers 501 Not Implemented, so the cap has to move to a Worker check or a HEAD-and-delete. MinIO implements it: its Java SDK ships a PostPolicy class with addContentLengthRangeCondition(). Test the endpoint you actually run before promising a cap.

A delete is not a refund. With versioning on, removing an oversized object leaves a noncurrent version, which stays billable.

The browser hides the reason. When S3 stops reading a body the client is still writing, the socket drops mid-send and fetch often reports a network error instead of the 400. Keep a file.size check so the common case produces a sentence a human can act on — and do not mistake it for a control: it is removable in one keystroke, and the policy is what remains.

Doing it right

1. Write the policy with the four conditions that matter. Bucket, key prefix, byte range, content type.

import boto3

s3 = boto3.client("s3", region_name="eu-west-3")
MAX_BYTES = 10 * 1024 * 1024          # 10 MiB

post = s3.generate_presigned_post(
    Bucket="uploads-prod",
    Key="incoming/${filename}",
    Fields={"Content-Type": "image/png"},
    Conditions=[
        {"bucket": "uploads-prod"},
        ["starts-with", "$key", "incoming/"],
        ["content-length-range", 1, MAX_BYTES],
        ["starts-with", "$Content-Type", "image/"],
        ["success_action_status", "201"],
    ],
    ExpiresIn=900,                     # 15 minutes
)
# post == {"url": "https://uploads-prod.s3.eu-west-3.amazonaws.com/", "fields": {...}}

success_action_status: 201 makes S3 answer with an XML document containing the ETag; the default is 204 with an empty body, which leaves you nothing to confirm the upload with.

2. Post the fields first, the file last. Any client that appends files after data gets the ordering right by construction.

import json, requests

post = json.load(open("/tmp/post.json"))
r = requests.post(
    post["url"],
    data=post["fields"],
    files={"file": ("huge.png", open("/tmp/too-big.bin", "rb"), "image/png")},
)
print(r.status_code, r.text[:300])

3. Replay the policy with a file that breaks it, before you ship it. Twelve megabytes against a ten-megabyte limit should return 400 with this body:

<Error>
  <Code>EntityTooLarge</Code>
  <Message>Your proposed upload exceeds the maximum allowed object size.</Message>
  <ProposedSize>12582912</ProposedSize>
  <MaxSizeAllowed>10485760</MaxSizeAllowed>
</Error>

A 403 with "Policy Condition failed" instead means the form is not sending the field the condition demands — usually a missing Content-Type on the file part.

4. Check what actually landed.

aws s3api head-object --bucket uploads-prod --key incoming/logo.png \
  --query '[ContentLength,ContentType]' --output text

5. Where POST is unavailable, enforce outside the signature. A Worker, a reverse proxy or a gateway can read Content-Length before accepting the body; HEAD-then-delete is the fallback and is not atomic. Name the one you rely on in the runbook.

How S3 Explorer fits

A policy states what a client may send; the object records what it sent, and the two disagree more often than expected — a Content-Type that is quietly application/octet-stream because the browser omitted it.

S3 Explorer shows both sides. Bucket and folder browsing reaches the uploaded key, the Content and Metadata tabs show the stored content type, size and user metadata exactly as S3 holds them, and the image preview shows whether that stored type is the one a browser will render. Multiple connection profiles let you post the same form to AWS and to a compatible endpoint, and compare the results. S3 Explorer runs on Windows, macOS and Linux.

The other half of the same problem — how long the permission lasts — is covered in presigned URLs expire at seven days.

Checklist

  • No upload path issues a presigned PUT to an untrusted client; each uses a POST policy with content-length-range.
  • The policy pins the bucket and a key prefix, so a client cannot write outside its own folder.
  • Content-Type has a starts-with condition and the form sends the field, rather than letting the browser guess.
  • A 12 MB file has been posted against a 10 MB policy and returned 400 EntityTooLarge with ProposedSize.
  • The multipart body was read directly: no field the policy does not declare, and the file is last.
  • Every provider in the deployment is tested for POST Object support; the R2-style 501 has a named fallback.
  • The client still checks file.size for the user's benefit, and the code says it is not a security control.
  • success_action_status is 201 and the returned ETag is stored against the object key.

Sources

Error strings and limits read from vendor documentation on 1 November 2026, quoted rather than paraphrased.