Enforcing Upload Size Limits with S3 POST Policies
Put ["content-length-range", 1, 10485760] in a presigned POST policy and S3 itself counts the bytes of the file part, tearing the request down with 400 EntityTooLarge before a single byte is committed to the bucket — no cooperation from the client required.
Every other size check in a direct-to-cloud flow is advisory. file.size in JavaScript is whatever the patched client says it is, the Content-Length header on a plain presigned PUT is unsigned unless you make it otherwise, and there is no IAM condition key for object size, so a bucket policy cannot express “nothing over 10 MiB” at all. The POST policy is the only place in the chain where the byte count is part of a signature. This article belongs to upload rate limiting and abuse protection inside backend validation and cloud storage architecture, and it assumes you have already decided that POST is your signing mode — presigned POST vs presigned PUT for browser uploads makes that call.
When to use this approach
- The client is untrusted: a public submission form, a mobile build you cannot force-update, a partner integration. Anything that can be patched can send 4 GB at a 4 MB form.
- You are paying per byte stored, per event delivered and per scanner invocation, and you would rather the rejection happen at the S3 edge than in a cleanup job you have to write, monitor and pay for.
- If your own API already sits in the byte path, or the caller is your first-party app and the upload is a single signed PUT with a known length, you do not need a policy — rate limiting presigned URL issuance bounds the damage a different way, by bounding how many grants exist at all.
Prerequisites
- Node 20+ with ESM and
@aws-sdk/client-s3plus@aws-sdk/s3-presigned-post, both 3.600.0 or later. - A signing identity with
s3:PutObjectonarn:aws:s3:::your-bucket/incoming/*and nothing wider. Signing never consults your permissions — the credential’s own scope is what stops a policy bug becoming a bucket-wide write. - Bucket CORS with
AllowedMethods: ["POST"]and your app origin, or the browser will not let your code read the error you worked so hard to produce. See fixing CORS preflight errors on S3 uploads. AWS_REGIONandINCOMING_BUCKETin the signing service’s environment.
What is actually in the policy
createPresignedPost returns a URL and a bag of form fields. One of those fields, Policy, is a base64-encoded JSON document listing every condition S3 will evaluate; another, X-Amz-Signature, is an HMAC over that exact base64 string. Decode the policy and you see the whole contract.
Two conditions carry the weight. content-length-range is a numeric, inclusive byte range applied to the content of the file part. starts-with on $key pins the destination folder while still letting the client contribute the tail of the object key. Everything else — content type, encryption header, success status — is exact-match string comparison.
Implementation
One signing function. The ceiling varies by declared content type, which is safe because the type is also a condition: a caller that asks for the 25 MiB PDF limit gets a policy that only accepts application/pdf.
// sign-post.ts — Node 20+, "type": "module"
// npm i @aws-sdk/client-s3 @aws-sdk/s3-presigned-post
import { S3Client } from "@aws-sdk/client-s3";
import { createPresignedPost } from "@aws-sdk/s3-presigned-post";
import { randomUUID } from "node:crypto";
const s3 = new S3Client({ region: process.env.AWS_REGION });
const BUCKET = process.env.INCOMING_BUCKET as string;
const POLICY_TTL_SECONDS = 600;
// Ceilings are per content type, in bytes. Nothing outside this map is signable.
const MAX_BYTES: Record<string, number> = {
"image/jpeg": 10 * 1024 * 1024,
"image/png": 10 * 1024 * 1024,
"image/webp": 10 * 1024 * 1024,
"application/pdf": 25 * 1024 * 1024,
};
export interface Grant {
url: string;
fields: Record<string, string>;
keyPrefix: string;
maxBytes: number;
}
export async function signSizeCappedPost(userId: string, contentType: string): Promise<Grant> {
const maxBytes = MAX_BYTES[contentType.toLowerCase()];
if (maxBytes === undefined) throw new Error(`unsupported content type: ${contentType}`);
const keyPrefix = `incoming/${userId}/${randomUUID()}-`;
const { url, fields } = await createPresignedPost(s3, {
Bucket: BUCKET,
// A trailing ${filename} makes S3 substitute the browser's own file name.
Key: `${keyPrefix}\${filename}`,
Expires: POLICY_TTL_SECONDS,
Conditions: [
["content-length-range", 1, maxBytes], // inclusive, applies to the file part
["starts-with", "$key", keyPrefix], // belt and braces over the SDK's own
{ "Content-Type": contentType }, // exact, case-sensitive
{ success_action_status: "201" }, // 201 + XML instead of a bare 204
{ "x-amz-server-side-encryption": "AES256" },
],
Fields: {
"Content-Type": contentType,
success_action_status: "201",
"x-amz-server-side-encryption": "AES256",
},
});
// maxBytes travels back to the client purely so the UI can fail fast and politely.
return { url, fields, keyPrefix, maxBytes };
}
Line-by-line walkthrough
["content-length-range", 1, maxBytes]takes two numbers, not strings, and both bounds are inclusive. A file of exactlymaxBytesis accepted. The lower bound of1costs nothing and removes the whole class of zero-byte objects that a cancelled picker, a revoked file handle or an iOS memory kill produces — those otherwise land as valid objects and generate metadata rows for files with no content.- **
Key: \${keyPrefix}${filename}`** — the escaped${filename}is literal text in the signed key, and S3 replaces it with thefilenameparameter from the file part'sContent-Dispositionat write time. Because of that suffix the SDK emits[“starts-with”, “$key”, keyPrefix]for you instead of an exact{ key }condition; the copy inConditionsis redundant but survives a refactor that drops the${filename}` form. - The client controls that filename, so treat it as hostile. S3 does not normalise
.., backslashes or control characters in a key, and the object will sit there happily until something downstream writes it to a real filesystem. Sanitise on the way in — the client snippet below does — and never derive a local path from the key without checking it again. { "Content-Type": contentType }is a byte comparison.image/JPEGfails. So doesimage/jpg, which some libraries emit and no browser does. Lowercase and map aliases before the lookup, and remember the type is still only a claim about the bytes; why browser MIME types are unreliable explains why the real check happens after the object lands.x-amz-server-side-encryptionappears in bothFieldsandConditions. That is the rule for every non-signature field:Fieldsare what the SDK hands the browser to submit,Conditionsare what S3 will tolerate. A field present in one and absent from the other fails the upload — in opposite directions, as the gotchas below show.Expires: 600is the policy’s own lifetime, embedded as theexpirationtimestamp. It is checked when S3 receives the request, so a ten-minute policy does not cut off an upload that started at minute nine.- The ceiling is decided server-side, per request. Nothing about the policy is static configuration, which is what makes per-plan and per-quota limits trivial.
The form the browser must POST
S3 parses the multipart body strictly in order and stops the moment it reaches the part named file. Every signed field has to be in front of it. This is not a style preference: fields after file are never read, so their conditions can never be satisfied.
// upload.ts — runs in the browser
import type { Grant } from "./sign-post.js";
export async function postWithSizeCap(file: File, grant: Grant): Promise<string> {
// A courtesy check for the UI only. It is not the limit; the policy is.
if (file.size > grant.maxBytes) {
throw new Error(`${file.name} is ${file.size} B, limit is ${grant.maxBytes} B`);
}
const form = new FormData();
for (const [name, value] of Object.entries(grant.fields)) form.append(name, value);
// Keep the tail so the extension survives; strip anything path-shaped.
const safeName = file.name.replace(/[^\w.\- ]+/g, "_").slice(-100);
form.append("file", file, safeName); // must be the final append
// Never set Content-Type here: the browser owns the multipart boundary.
const res = await fetch(grant.url, { method: "POST", body: form });
const xml = await res.text();
if (res.status !== 201) {
const code = xml.match(/<Code>([^<]+)<\/Code>/)?.[1] ?? "Unknown";
throw new Error(`S3 rejected the upload: ${res.status} ${code}`);
}
return xml.match(/<Key>([^<]+)<\/Key>/)?.[1] ?? "";
}
FormData preserves insertion order, so iterating grant.fields first and appending file last is sufficient. If you build the body by hand instead, implementing multipart/form-data in vanilla JavaScript shows the boundary framing you have to reproduce byte for byte. Get the ordering wrong and S3 answers 400 with <Code>InvalidArgument</Code><Message>Bucket POST must contain a field named 'file'. If it is specified, please check the order of the fields.</Message> — a message that is confusing precisely because the field is there.
What S3 does with an oversize body
The rejection is not free of network cost, and being honest about that matters when you size expectations. S3 reads the request, counts the file part as it streams, and the instant the counter passes the maximum it stops reading and responds 400 EntityTooLarge:
<?xml version="1.0" encoding="UTF-8"?>
<Error>
<Code>EntityTooLarge</Code>
<Message>Your proposed upload exceeds the maximum allowed size</Message>
<ProposedSize>12582912</ProposedSize>
<MaxSizeAllowed>10485760</MaxSizeAllowed>
<RequestId>K3P0X6ZQ1V8T2N9E</RequestId>
<HostId>7Yq3+bJ0lU2cV1oX8kR5tHn6dQ4wS0mA1eF7pL9zC3vB2gN8jM6xT4rY5uI1oP0k=</HostId>
</Error>
There is a sharp edge in the third box. When S3 stops reading a body the client is still writing, the socket is torn down mid-send, and fetch frequently rejects with TypeError: Failed to fetch instead of resolving with the 400. You never see the XML. That is why the client snippet keeps its own file.size check: not as a security control — it is trivially removable — but so the common case produces a sentence a user can act on rather than a generic network failure. The same asymmetry shows up with proxy-enforced ceilings, where the surfaced status is a 413 and the body is equally likely to vanish.
A zero-byte file trips the other bound, with the mirror-image response: 400 and <Code>EntityTooSmall</Code><Message>Your proposed upload is smaller than the minimum allowed size</Message><ProposedSize>0</ProposedSize><MinSizeAllowed>1</MinSizeAllowed>.
Varying the ceiling per caller
Because the policy is minted per request, the limit is a runtime decision. A free-tier account, a paying account and an anonymous form can share one endpoint and get three different numbers, and none of them can borrow another’s.
Two constraints bound the top of that ladder. A single POST cannot exceed 5 GiB, because that is the object-size limit for one request; anything larger has to be assembled with the multipart upload API, and presigned POST cannot drive UploadPart. The threshold at which you should switch is discussed in multipart vs single-PUT for files under 100 MB. And a size ceiling says nothing about what the bytes are — a 9 MiB archive that inflates to 40 GiB passes every condition here, which is the subject of detecting and blocking zip bomb uploads.
The presigned PUT equivalent
If you are on PUT, the one comparable lever is a signed Content-Length. Add ContentLength to the PutObjectCommand and put content-length in signableHeaders; the browser computes the header from the body and cannot override it, since Content-Length is a forbidden header name in fetch. Any body of a different length then fails signature verification with 403 SignatureDoesNotMatch.
The differences are real. It is an exact match, not a range, so the client has to declare file.size to your API before signing and you get one grant per exact byte count. A non-browser client that declares a smaller length simply gets a truncated object rather than an error. And a signed length forces a round trip you would otherwise skip. A POST policy expresses 1 … 10 485 760 in one grant, reusable for any file in that range.
Configuration gotchas
Invalid according to Policy: Extra input fields: x-amz-meta-owner
Status 403, code AccessDenied. Every field in the form except X-Amz-Signature, file and AWSAccessKeyId must be named by a condition. This bites when a developer adds a metadata field to the client and not to the signer. The inverse mistake — a condition with no matching field — is quieter: an exact-match condition for a field the form never sends fails with Invalid according to Policy: Policy Condition failed: ["eq", "$x-amz-meta-owner", "u42"], also 403.
Invalid according to Policy: Policy expired.
Status 403, code AccessDenied. Expires is evaluated against the wall clock when the request arrives, so a policy handed to a page that sat open in a background tab for twenty minutes is dead. Sign at the moment of upload, not at page render, and if you must pre-sign, refresh on visibilitychange. Ten minutes is a reasonable TTL; extending it to an hour mostly widens the window in which a leaked grant is useful.
Your POST request fields preceding the upload file were too large.
Status 400, code MaxPostPreDataLengthExceededError. Everything before the file part must fit in 20 KB. A normal policy uses roughly 700–900 bytes, so you only meet this by stuffing base64 thumbnails or long JSON blobs into x-amz-meta-* fields. Put large metadata in your own database keyed by the object key — the schema patterns in how to index file metadata in PostgreSQL are the right home for it.
The 400 is invisible in the browser
If your bucket’s CORS rules do not match the request origin, S3 still rejects the oversize upload correctly, but the response carries no Access-Control-Allow-Origin header, so the browser discards it and your code sees an opaque failure. The DevTools console says the request “has been blocked by CORS policy” and the network panel shows the 400 you cannot read. Add the origin to AllowedOrigins and keep AllowedMethods: ["POST"]; a FormData POST is a simple request and skips preflight, which is exactly why this is easy to miss until the first rejection.
Verification
Prove the ceiling with a real oversize body rather than trusting the policy JSON. This drives your own signing endpoint and needs only curl and jq.
#!/usr/bin/env bash
set -euo pipefail
SIGN_ENDPOINT=${SIGN_ENDPOINT:-http://localhost:3000/uploads/sign}
GRANT=$(curl -sS -X POST "$SIGN_ENDPOINT" \
-H 'content-type: application/json' \
-d '{"contentType":"image/jpeg"}')
URL=$(jq -r .url <<<"$GRANT")
# One "-F name=value" pair per signed field, in the order the signer returned them.
mapfile -t FIELDS < <(jq -r '.fields | to_entries[] | "-F", "\(.key)=\(.value)"' <<<"$GRANT")
head -c 12582912 /dev/urandom > oversize.jpg # 12 MiB against a 10 MiB policy
curl -sS -o reject.xml -w 'HTTP %{http_code}\n' \
"${FIELDS[@]}" -F "file=@oversize.jpg;type=image/jpeg" "$URL"
grep -o '<Code>[^<]*</Code>\|<MaxSizeAllowed>[^<]*</MaxSizeAllowed>' reject.xml
# HTTP 400
# <Code>EntityTooLarge</Code>
# <MaxSizeAllowed>10485760</MaxSizeAllowed>
: > empty.jpg # the other bound
curl -sS -o small.xml -w 'HTTP %{http_code}\n' \
"${FIELDS[@]}" -F "file=@empty.jpg;type=image/jpeg" "$URL"
grep -o '<Code>[^<]*</Code>' small.xml
# HTTP 400
# <Code>EntityTooSmall</Code>
head -c 10485760 /dev/urandom > exact.jpg # exactly at the ceiling: accepted
curl -sS -o ok.xml -w 'HTTP %{http_code}\n' \
"${FIELDS[@]}" -F "file=@exact.jpg;type=image/jpeg" "$URL"
# HTTP 201
Three assertions matter here. A 201 on the 12 MiB body means content-length-range ended up in Fields rather than Conditions and is being ignored. A 400 on exact.jpg means you are treating the upper bound as exclusive somewhere. And curl sends Expect: 100-continue for bodies over 1 KB, so the rejection may come back before the file is transferred — a browser never does that, which is why the same failure feels instant in the terminal and slow in the product.
Finally, confirm that nothing landed. aws s3 ls s3://$INCOMING_BUCKET/incoming/u42/ --recursive should list only exact.jpg’s object. Back the policy with a lifecycle rule that expires anything under incoming/ that never gets promoted, as in setting up S3 lifecycle rules for temporary uploads, and with a real content check once the object exists — validating file signatures with libmagic in Node.js covers that half.
Frequently Asked Questions
Does content-length-range measure the whole request or just the file?
Just the content of the file part. Boundary markers, field names and the other parts are not counted, which is why a file of exactly 10 485 760 bytes passes a 10 485 760 ceiling even though the HTTP body is roughly a kilobyte larger. Do not subtract a safety margin for the envelope — you will only reject files that should have been accepted.
Can I enforce a maximum object size with a bucket policy instead?
No. IAM has no condition key for object size, so there is nothing to write a Deny against. Your options are a POST policy, a signed exact Content-Length on a PUT, proxying the bytes through something that counts them, or detecting oversize objects after they exist and deleting them. Only the first two stop the write.
What lower bound should I set?
1 unless you have a measured reason for more. It costs nothing and eliminates zero-byte objects, which are the most common junk a real upload flow produces. Format-aware minimums are tempting — a JPEG cannot be under about 130 bytes — but they buy almost nothing and will eventually reject a legitimate file you did not think about, such as a 1×1 tracking pixel a customer genuinely wants to store.
Can the client just read the policy and work around the limit?
It can read it — base64 is not encryption — but there is nothing to work around. Altering any byte of the policy breaks X-Amz-Signature, and the client cannot re-sign without your secret key. Assume the limit is public, and return maxBytes alongside the grant so the UI can say “this file is 24 MB, the limit is 10 MB” instead of guessing.