skip to content

You let browsers upload directly to S3. With a presigned PUT URL, what stops a client from writing a key you did not intend or pushing a 5 GB file, and what does a presigned POST policy give you that the PUT does not?

level: middleimportance: should knowfreq 46%

answer

  1. the key is inside the signature
  2. PUT cannot bound the body size
  3. POST signs a policy, not a request
  4. content-length-range and starts-with
  5. S3 enforces it, not your JavaScript

basics

~20 s

A presigned PUT pins the bucket, key and method into the signature, so the client cannot redirect the write — but it cannot bound the body size. A presigned POST signs a policy document whose conditions, including content-length-range, are enforced by S3 on upload.

solid answer

~50 s

With a presigned PUT the key is fixed at signing time: it is part of the canonical request, so a client that edits the path gets `SignatureDoesNotMatch`. Key control therefore comes free, provided your server derives the key rather than accepting one from the caller. What PUT cannot do is bound the size — you would have to sign an exact `Content-Length`, which means knowing the byte count in advance. A presigned POST signs a **policy document** instead: a base64 JSON blob with `expiration` and a `conditions` list that S3 evaluates against the submitted form. That is where you get `["content-length-range", 1, 10485760]`, `["starts-with", "$key", "uploads/tenant-42/"]` and a pinned `Content-Type`. So: PUT for a trusted-ish client with a known key, POST policy for untrusted browsers where you must cap size and constrain the key shape.

code

python · 18 lines
python
import boto3

s3 = boto3.client("s3")

post = s3.generate_presigned_post(
    Bucket="my-bucket",
    Key="uploads/tenant-42/${filename}",
    Fields={"Content-Type": "image/png"},
    Conditions=[
        {"Content-Type": "image/png"},
        ["starts-with", "$key", "uploads/tenant-42/"],
        ["content-length-range", 1, 10485760],
    ],
    ExpiresIn=300,
)

print(post["url"])
print(post["fields"])

go deeper

for a junior

Know both shapes exist: a presigned PUT URL the browser writes to directly, and a presigned POST form whose hidden fields carry a signed policy.

for a middle

Explain that the key is baked into the PUT signature while size cannot be, and that a POST policy's conditions — content-length-range, starts-with — are checked by S3 on submission.

for a senior

Demonstrate the untrusted-client mindset: server-derived keys, S3-enforced size caps, minute-scale expiries, a signing role scoped to one prefix, and post-upload verification before anything is served.

for a principal

Own the boundary decision — direct-to-S3 removes your inspection point, so the organization needs a standard quarantine-and-promote pipeline rather than every team inventing its own upload validation.

## Two different signing schemes S3 offers two ways to let a browser upload without credentials, and they are not variations of the same thing. **Presigned PUT.** You sign a canonical request — method `PUT`, bucket, key, signed headers — and hand back a URL. The browser does `fetch(url, { method: 'PUT', body: file })`. Simple, one round trip, and the body streams straight up. **Presigned POST.** You sign a *policy document*, and hand back a URL plus a set of form fields. The browser builds a `multipart/form-data` POST whose last field is the file. S3 checks every submitted field against the policy's conditions before accepting the body. ## What the PUT signature actually pins The canonical request includes the HTTP method, the bucket host, the object key and the list of signed headers. Any change breaks the HMAC, so: - The client **cannot change the key**. A URL signed for `uploads/tenant-42/abc123.png` writes only there. This is the answer to "how do you stop them overwriting someone else's object" — but only if your API generates the key itself. If your endpoint takes a `key` parameter from the client and signs it, you have handed out a write primitive over everything the signing role can touch. Derive the key from the authenticated user: tenant prefix plus a server-generated identifier. - If you include `Content-Type` in the signed headers, the client must send exactly that value or the request fails. Useful for pinning `image/png`, though it constrains the declared type, not the actual bytes. - The client **cannot be bounded on size**. `Content-Length` is a header, so signing it would require the exact byte count up front — you would have to ask the browser for the size and then trust it, which defeats the purpose. In practice, a presigned PUT accepts anything the single-PUT path allows. ## What the POST policy adds The policy is base64-encoded JSON with an `expiration` timestamp and a `conditions` array, signed with SigV4. Conditions come in three shapes: exact match (`{"bucket": "my-bucket"}`), `starts-with` (`["starts-with", "$key", "uploads/"]`), and the special `content-length-range` with a minimum and maximum in bytes. Every field the form submits must be covered by a condition, or S3 rejects the upload — a strict allowlist, which is the right default for untrusted input. The SDK helper returns the URL and the exact fields to render: ```python post = s3.generate_presigned_post( Bucket="my-bucket", Key="uploads/tenant-42/${filename}", Fields={"Content-Type": "image/png"}, Conditions=[ {"Content-Type": "image/png"}, ["starts-with", "$key", "uploads/tenant-42/"], ["content-length-range", 1, 10485760], ], ExpiresIn=300, ) ``` The returned `fields` include `key`, `policy`, `x-amz-algorithm`, `x-amz-credential`, `x-amz-date` and `x-amz-signature` — plus `x-amz-security-token` when signing with temporary credentials. Render them as hidden inputs, put the file input last (S3 ignores anything after it), and the form posts straight to the bucket. `success_action_redirect` or `success_action_status` controls what the browser sees afterwards, which matters for a plain HTML form with no JavaScript. The critical property is that `content-length-range` is enforced **by S3**, not by your page. A malicious client that skips your JavaScript still gets its oversized upload rejected — S3 refuses it rather than storing it and billing you. ## Choosing between them Reach for the **PUT** when the client is your own application code, the key is fully determined server-side, and a size cap is either unnecessary or enforced elsewhere. It is one signature, one request, and trivially resumable by re-signing. Reach for the **POST policy** when a browser you do not control uploads user-chosen files and you must bound size, or when you want the key to be partly client-influenced but constrained by a prefix — `starts-with` is what makes "the user picks the filename, we pick the folder" safe. ## Defence in depth Neither scheme validates content. A `content-length-range` cap and a pinned `Content-Type` do not stop someone uploading a renamed executable inside the size limit. Real systems land uploads in a quarantine prefix or a separate bucket, verify the object server-side after the fact, and only then move it into the serving location. Keep the presigned expiry to minutes, scope the signing role to the upload prefix and the single action it needs, and remember that a size cap you enforce only in front-end JavaScript is not a cap at all.

  • Your upload endpoint accepts the object key from the browser and presigns it. What is the exploit?
    A caller supplies another tenant's key — or a path that overwrites a served asset — and your server happily signs it, because the signature only proves the URL came from you, not that the key was legitimate. The fix is to derive the key entirely server-side from the authenticated identity, or to constrain it with a `starts-with` condition on a POST policy so the client can influence only the filename.
  • Why is enforcing the file-size limit in front-end JavaScript not enough?
    The page is under the attacker's control; they can call the presigned URL directly with curl. With a plain presigned PUT there is nothing server-side to stop them, so you store and pay for the oversized object. A POST policy's `content-length-range` is evaluated by S3 itself, which is why it is the right control for untrusted browsers.
  • Does pinning Content-Type in the policy guarantee the object really is that type?
    No. It constrains the declared header, not the bytes. A client can send arbitrary content labelled `image/png`. Treat it as hygiene for how the object will later be served, and do real verification after the fact — land uploads in a quarantine prefix, inspect them server-side, and only then promote them to the location your application serves from.

saying these in an interview costs you the question

  • Thinks the client can change the key in a presigned PUT URL
  • Enforces the size limit only in browser JavaScript
  • Lets the caller choose the object key and signs it as given
  • Believes a pinned Content-Type proves what the bytes are
  • Says presigned POST is just an older form of presigned PUT

context