Enforce file-size and content-type limits on presigned uploads
What S3, R2, Vercel Blob, GCS, and Azure check when the browser uploads to a signed URL, and how to verify the actual bytes when the signature can't.
A signed upload URL can only enforce what the storage service checks when the bytes arrive. S3 and Google Cloud Storage check size and Content-Type through a POST policy, and a presigned PUT from either pins the type but not the size. Vercel Blob checks both on a presigned PUT. R2 and Azure can’t cap size on a signed URL at all. Anything the service can’t check, your server has to: either by receiving the bytes itself, or by inspecting the object after it lands and deleting it.
The catch that applies everywhere: every check here is against the Content-Type the client claims, not what the bytes are. An HTML file labelled image/png passes all of them.
Before you start
- Written against files-sdk 3.0. The S3 behavior below was reproduced against a local MinIO server (
RELEASE.2025-09-06T17-38-46Z); provider behavior for the others comes from their docs, linked where it’s used. - The adapter for your provider, installed as its docs page says. For S3’s
POSTpolicy, that includes@aws-sdk/s3-presigned-post.
What each service enforces
Each row comes from that adapter’s signedUploadUrl source. “Throws” means Files SDK refuses to sign rather than hand back a URL that ignores the option.
| Provider and adapter | Contract | Max size | Min size | Content-Type |
|---|---|---|---|---|
S3, s3() with maxSize |
POST policy |
content-length-range, checked by S3 |
minSize, default 1 |
eq $Content-Type, checked by S3 |
S3, s3() without maxSize |
Presigned PUT |
None | None | Signed; a different type fails the signature |
S3-compatible, s3Fetch() |
Presigned PUT |
Throws | None | Signed; a different type fails the signature |
R2, either client |
Presigned PUT |
Throws | None | Signed; a different type fails the signature |
| Vercel Blob, public or private | Presigned PUT |
maximumSizeInBytes, checked by Vercel |
A positive minSize throws |
allowedContentTypes, checked by Vercel |
GCS, gcs() with maxSize |
V4 POST policy |
content-length-range, checked by GCS |
minSize, default 1 |
eq $Content-Type, checked by GCS |
GCS, gcs() without maxSize |
V4 signed PUT |
None | None | Signed |
Azure, azure() |
SAS PUT |
Throws | A positive minSize throws |
Throws |
“Signed” applies when you pass contentType. The S3 rows also cover every s3() wrapper (Hetzner, Wasabi, and the rest), and MinIO and RustFS on either client. Without a contentType, their PUT URLs sign only host, and the client can send any type.
Sources for each row:
- S3 and GCS document
content-length-rangeand exact-match (eq) conditions for S3 POST policies and GCS policy documents. The adapters add["content-length-range", minSize ?? 1, maxSize]and, when you passcontentType,["eq", "$Content-Type", contentType]. - R2 doesn’t support presigned
POST, so there’s no policy to carry a size. Cloudflare documents that a signedContent-Typemakes a mismatched upload fail with403 SignatureDoesNotMatch, which holds only when the type is in the signature. Both R2 clients put it there. - Vercel Blob enforces
allowedContentTypesandmaximumSizeInBytesat the CDN for presignedPUTs. The adapter mapscontentTypeandmaxSizeonto them. Vercel has no minimum, so a positiveminSizethrows. - Azure SAS URLs carry no size or type condition. The adapter throws on
maxSize, a positiveminSize, andcontentType.
PUT contracts and POST policies
The two contracts sign different things.
A presigned PUT signs the method, the key, the expiry, and the headers listed in its X-Amz-SignedHeaders (or X-Goog-SignedHeaders) query parameter. Nothing in it describes the body, so a PUT URL can’t limit size. It can pin Content-Type only if that header is in the signed list.
A presigned POST signs a policy document: an expiry plus a list of conditions on the form. The browser sends a multipart/form-data request with the signed fields first and the file last, and the service checks each condition against the form. This is the policy s3() produced for maxSize: 1024 and contentType: "image/png":
[
["content-length-range", 1, 1024],
["eq", "$Content-Type", "image/png"],
{ "Content-Type": "image/png" },
{ "bucket": "uploads" },
{ "key": "post/a.png" }
]
The algorithm, credential, and date conditions are trimmed here. The key condition pins the object key, so the form can’t be replayed against another key.
files.signedUploadUrl() returns either shape as a { method: "PUT" | "POST" } union, and its reference page shows the browser code for both. The gateway’s useFiles().upload(file) handles both for you.
Reproduce the S3 behavior
Start MinIO with its defaults, create an uploads bucket, and run this with bun scripts/check-policy.ts:
import { createFiles } from "files-sdk";
import { s3 } from "files-sdk/s3";
// A local MinIO server started with its default port and credentials.
const files = createFiles({
adapter: s3({
bucket: "uploads",
region: "us-east-1",
endpoint: "http://127.0.0.1:9000",
forcePathStyle: true,
credentials: { accessKeyId: "minioadmin", secretAccessKey: "minioadmin" },
}),
});
const outcome = async (res: Response) =>
`${res.status} ${/<Code>(.+?)<\/Code>/.exec(await res.text())?.[1] ?? ""}`;
// 1. A POST policy capped at 1 KiB, then a 2 KiB body.
const post = await files.signedUploadUrl("policy/a.png", {
expiresIn: 300,
contentType: "image/png",
maxSize: 1024,
});
if (post.method === "POST") {
const form = new FormData();
for (const [name, value] of Object.entries(post.fields)) {
form.append(name, value);
}
form.append("file", new Blob([new Uint8Array(2048)]));
const res = await fetch(post.url, { method: "POST", body: form });
console.log("oversized POST:", await outcome(res));
}
// 2. A presigned PUT for image/png, then a text/html request.
const put = await files.signedUploadUrl("policy/b.png", {
expiresIn: 300,
contentType: "image/png",
});
if (put.method === "PUT") {
const signed = new URL(put.url).searchParams.get("X-Amz-SignedHeaders");
console.log("signed headers:", signed);
const res = await fetch(put.url, {
method: "PUT",
headers: { "Content-Type": "text/html" },
body: "<!doctype html>",
});
console.log("mismatched PUT:", await outcome(res));
}
Against MinIO, the script printed 400 EntityTooLarge for the oversized POST, content-type;host as the signed headers, and 403 SignatureDoesNotMatch for the mismatched PUT. A wider run against the same server gave these results:
| Request | MinIO response |
|---|---|
POST, 512 bytes, within the policy |
204 |
POST, 2048 bytes against a 1024-byte maximum |
400 EntityTooLarge |
POST, 0 bytes (default minSize of 1) |
400 EntityTooSmall |
POST with the Content-Type field changed to text/html |
403 AccessDenied (“Policy Condition failed”) |
POST of HTML bytes with the Content-Type field left at image/png |
204, stored as image/png |
s3() PUT signed for image/png, sent as image/png |
200 |
s3() PUT signed for image/png, sent as text/html |
403 SignatureDoesNotMatch |
s3() PUT signed for image/png, sent with a 5 MiB body |
200 |
s3() PUT signed with no contentType, sent as text/html |
200, stored as text/html |
s3Fetch() PUT signed for image/png, sent as text/html |
403 SignatureDoesNotMatch |
Which headers a SigV4 URL covers is decided by the client that signs it, and a server can only reject a header the signature covers. @aws-sdk/s3-request-presigner (3.1148, the version checked) leaves content-type out of the signature by default, so s3() adds it back whenever you pass contentType. s3Fetch() signs with aws4fetch, which includes it too. Pointed at the same MinIO server, hetzner(), wasabi(), r2(), and minio() (the last two on both clients) all listed content-type;host and got 403 for a text/html body. In files-sdk 2.6 and earlier, s3() signed only host even with a contentType, so on those versions only the POST path enforces the type.
The 5 MiB row is the limit of a PUT: the signature says nothing about the body, so the type is pinned and the size isn’t.
Make S3 enforce the size
On S3, pass maxSize. The POST policy then enforces both size and type:
import { ALLOWED_TYPES, MAX_BYTES, files } from "./storage";
export async function signUpload(userId: string, type: string) {
if (!ALLOWED_TYPES.has(type)) {
throw new Error(`${type} uploads are not allowed`);
}
const key = `users/${userId}/${crypto.randomUUID()}`;
// With maxSize, S3 returns a POST policy:
// content-length-range [1, MAX_BYTES] and Content-Type = type.
const upload = await files.signedUploadUrl(key, {
expiresIn: 300,
contentType: type,
maxSize: MAX_BYTES,
});
return { key, upload };
}
import { createFiles } from "files-sdk";
import { s3 } from "files-sdk/s3";
export const files = createFiles({
adapter: s3({ bucket: "uploads", region: "us-east-1" }),
});
export const MAX_BYTES = 25 * 1024 * 1024; // 25 MiB
export const ALLOWED_TYPES = new Set([
"image/png",
"image/jpeg",
"application/pdf",
]);
If a client can only send a PUT (some upload widgets can’t build a form), call signedUploadUrl with contentType and without maxSize. S3 then refuses an upload with a different type, but not a larger one, so check the size after the upload lands.
What the gateway does with maxUploadSize
The gateway’s keyless upload(file) starts with the size the browser declares. A file that claims more than maxUploadSize gets 422 with reason: "size" and the message upload exceeds maxUploadSize, before anything is signed. Otherwise the gateway asks the adapter for a presigned target with maxSize: maxUploadSize, minSize: 0, and the file’s claimed type. If files.capabilities.signedUpload says the adapter can’t sign that (no direct uploads, no maxSize while maxUploadSize is set, or no contentType for a typed file), or signedUploadUrl refuses with an Invalid or Unsupported error, the gateway hands the browser a proxy target instead: a PUT to /api/files?op=proxy that streams through your server. There’s no error and no log line. These are the targets the presign step returned for each configuration:
| Configuration | Target |
|---|---|
s3(), no maxUploadSize |
Presigned PUT to S3 |
s3(), maxUploadSize set |
Presigned POST to S3 |
gcs(), maxUploadSize set |
Presigned POST to GCS |
R2 (either client), no maxUploadSize |
Presigned PUT to R2, with the type signed |
R2, maxUploadSize set |
Proxy through your server |
s3Fetch(), maxUploadSize set |
Proxy through your server |
azure(), any upload with a type |
Proxy through your server |
vercelBlob(), public or private |
Presigned PUT with maximumSizeInBytes (from the source; not run) |
s3() plus validation() or contentType() |
Proxy through your server |
Two of those rows surprise people:
- R2 with
maxUploadSizeproxies because R2 refusesmaxSize. The limit then holds, since the proxy counts bytes and aborts past it, but every byte now crosses your server. - Azure proxies whenever the browser sends a type, and the browser client always sends one (
application/octet-streamwhen the file has none). Azure refusescontentType.
The declared size is only the browser’s claim, so the gateway checks the real one as well. After a direct upload, the complete step heads the object, and if it’s larger than maxUploadSize, deletes it and returns an error entry for that key. With a 1000-byte limit, on the memory adapter and on s3() against MinIO, a presign that declared 2000 bytes got 422. One that declared 500 bytes, followed by a 2000-byte object written to the minted key, came back from complete as uploaded object is 2000 bytes, exceeds maxSize 1000, and the object was gone afterwards. The proxy path stops the bytes sooner: with the memory adapter, a 2000-byte body streamed there got 422 upload exceeds maxSize, and nothing was stored.
Complete also checks that the token’s key lies under the calling request’s keyPrefix. A token minted for another user gets an Unauthorized entry, upload token was not issued for this caller, and doesn’t reveal the key.
Check the bytes, not the claim
Every policy above trusts the type the client declares. To decide the type from the content, sniff it. Files SDK gives you two places to do that.
Through your server: plugins
contentType() reads the first 512 bytes of each upload and compares their magic bytes with the declared type. With onMismatch: "reject", a mismatch throws before anything is stored. validation() enforces size and an allowed-type list. Put contentType() first so validation() checks the corrected type:
import { createFiles } from "files-sdk";
import { contentType } from "files-sdk/content-type";
import { s3 } from "files-sdk/s3";
import { validation } from "files-sdk/validation";
export const files = createFiles({
adapter: s3({ bucket: "uploads", region: "us-east-1" }),
plugins: [
// Order matters: sniff first, then check the corrected type.
contentType({ onMismatch: "reject" }),
validation({
maxSize: 25 * 1024 * 1024,
allowedTypes: ["image/png", "image/jpeg", "application/pdf"],
}),
],
});
Both plugins refuse to sign upload URLs, because a direct upload would skip them. files.signedUploadUrl() throws contentType: signedUploadUrl() bypasses magic-byte sniffing (the client uploads directly, never through the plugin); upload through the Files instance to enforce it. validation() refuses the same way whenever it has a size or type rule.
Behind the gateway, that refusal sends keyless uploads to the proxy path, so the plugins see every byte. With the memory adapter, uploading HTML bytes as avatar.png through that path returns 422 with code Validation and the message contentType: "users/42/….png" is declared "image/png" but its bytes are "text/html". The plugin throws an Invalid error, which the gateway sends as a 422, and the message includes the full storage key.
After a direct upload: sniff and delete
If the bytes go straight to storage, check them before your app trusts the object. Read the first 512 bytes with a range request and run the plugin’s exported detectContentType:
import { detectContentType } from "files-sdk/content-type";
import { ALLOWED_TYPES, MAX_BYTES, files } from "./storage";
// Run before your app relies on an object the browser uploaded directly.
export async function verifyUpload(key: string) {
const stored = await files.head(key);
const firstBytes = await files.download(key, {
range: { start: 0, end: 511 },
});
const sniffed = detectContentType(
new Uint8Array(await firstBytes.arrayBuffer())
);
const ok =
stored.size <= MAX_BYTES &&
sniffed !== undefined &&
ALLOWED_TYPES.has(sniffed) &&
sniffed === stored.contentType;
if (!ok) {
await files.delete(key);
throw new Error("Upload rejected: size or content doesn't match");
}
return { key, size: stored.size, contentType: sniffed };
}
Against MinIO, with signUpload minting POST policies for image/png, both a real PNG and an HTML file labelled image/png uploaded with 204. verifyUpload returned the PNG’s metadata and deleted the HTML file.
This is a check, not prevention. The bytes were written and billed before it ran, and the object is reachable by anyone holding a URL to it until you delete it, so don’t hand out download URLs for an object until it passes. A few more limits:
detectContentTyperecognizes images, PDF, HTML, SVG, and XML. For anything else it returnsundefined, which this function treats as a rejection. Relax that for types such as video or ZIP, where a sniff can’t confirm much.- Private Vercel Blob doesn’t support range reads. Download the whole object there, or skip the sniff and serve the file only as an attachment.
- Uploads nobody ever verifies pile up. Upload to a staging prefix and expire it with a lifecycle rule.
Pick an enforcement point
- S3 and GCS: sign with
maxSizeso the service enforces size and type. Without it, aPUTstill pins the type, but not the size. Sniff after landing if you’ll ever serve the files inline. - Vercel Blob:
maxSizeandcontentTypeare both enforced at the CDN. Reject empty files in your app, since there’s no minimum. Fix Vercel’s 413 upload error has the full upload flow. - R2 and Azure: storage can’t cap size. Either proxy uploads through your server with
maxUploadSizeor the plugins, or check each object after it lands as in the R2 uploader guide. R2 still pins the type on either client; Azure can’t. - Untrusted files you serve back inline: sniff them with
contentType({ onMismatch: "reject" })on the proxy path, or serve them withContent-Disposition: attachmentso a mislabelled HTML file downloads instead of running.