Skip to content
Files SDK
Esc
↑↓navigate↵open⌘Jpreview
On this page

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 POST policy, 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-range and exact-match (eq) conditions for S3 POST policies and GCS policy documents. The adapters add ["content-length-range", minSize ?? 1, maxSize] and, when you pass contentType, ["eq", "$Content-Type", contentType].
  • R2 doesn’t support presigned POST, so there’s no policy to carry a size. Cloudflare documents that a signed Content-Type makes a mismatched upload fail with 403 SignatureDoesNotMatch, which holds only when the type is in the signature. Both R2 clients put it there.
  • Vercel Blob enforces allowedContentTypes and maximumSizeInBytes at the CDN for presigned PUTs. The adapter maps contentType and maxSize onto them. Vercel has no minimum, so a positive minSize throws.
  • Azure SAS URLs carry no size or type condition. The adapter throws on maxSize, a positive minSize, and contentType.

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 maxUploadSize proxies because R2 refuses maxSize. 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-stream when the file has none). Azure refuses contentType.

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:

  • detectContentType recognizes images, PDF, HTML, SVG, and XML. For anything else it returns undefined, 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 maxSize so the service enforces size and type. Without it, a PUT still pins the type, but not the size. Sniff after landing if you’ll ever serve the files inline.
  • Vercel Blob: maxSize and contentType are 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 maxUploadSize or 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 with Content-Disposition: attachment so a mislabelled HTML file downloads instead of running.

Last updated on

Was this page helpful?