Can an upload signature enforce avatar size and file type limits?

Presigned upload URLs carry size and MIME conditions on some providers and not others. What Infrai's image API checks, what it won't, and where the real gate belongs.

Short answer: only if the thing you’re signing is a policy document rather than a plain signed URL. Amazon S3’s browser POST policy pins a content-length-range and a Content-Type prefix, so the bucket refuses a 400 MB file before it lands. A presigned PUT can’t do that. Infrai issues no upload signature at all — image bytes arrive on an authenticated POST from your own server — which moves the gate one hop earlier, into your handler.

That’s not a worse place for it. It’s the only place that holds for every provider you might move to later, and it’s where you can be strict about the thing a signature was never going to catch anyway: what the bytes actually are.

Two different abuses hide behind “free file host”. One is storage — a .zip renamed to .png, parked in your bucket forever. The other is bandwidth: someone hotlinks a 4 MB avatar into a forum signature and you pay egress on every page view. A byte cap fixes the second. Only re-encoding fixes the first.

What a signature can actually carry

MechanismSize limit inside the signatureType limit inside the signatureWho enforces it
S3 browser POST policyyes — content-length-rangeyes — starts-with on Content-Typethe bucket, before the object exists
S3 presigned PUTnoonly the header you signed, which the client controlsnothing, at upload time
Cloudinary signed upload presetyes — max_file_size on the presetyes — allowed_formatsCloudinary, from a server-side preset
imgixnot applicable — it’s a read-side transform CDNnot applicablenot applicable
Infrai image APIno upload signature to embed conditions innoyour handler, then the re-encode

If you specifically want the rule to live inside the credential the browser holds, S3’s POST policy and Cloudinary’s signed presets are the two mainstream ways to get it, and Cloudinary’s is far less fiddly to assemble. The catch is that both bind you to that provider’s upload endpoint. A policy document is not portable — you rewrite it if you move.

The cap that always holds: count bytes as they arrive

Content-Length is a claim, not a measurement. Cap the stream itself and abort mid-body when it crosses your limit, so a lying header costs you 2 MB of buffer rather than 400.

Then sniff the first few bytes. Four bytes of magic number rejects the renamed archive that a MIME check waves through, and it costs nothing.

// avatar-intake.mjs — Node 22, run with: node avatar-intake.mjs ./upload.png
import { readFile } from "node:fs/promises";

const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("set INFRAI_API_KEY (yours looks like your_infrai_api_key)");

const MAX_BYTES = 2 * 1024 * 1024;
const MAGIC = [
  { ext: "png", sig: [0x89, 0x50, 0x4e, 0x47] },
  { ext: "jpeg", sig: [0xff, 0xd8, 0xff] },
  { ext: "webp", sig: [0x52, 0x49, 0x46, 0x46] },
];

const sniff = (buf) => MAGIC.find((m) => m.sig.every((b, i) => buf[i] === b))?.ext ?? null;

async function call(url, payload) {
  const res = await fetch(url, {
    method: "POST",
    headers: { authorization: `Bearer ${KEY}`, "content-type": "application/json" },
    body: JSON.stringify(payload),
  });
  const json = await res.json();
  if (!res.ok || json.ok === false) {
    throw new Error(`${url} -> ${res.status} ${json?.error?.code ?? "unknown"}: ${json?.error?.message ?? ""}`);
  }
  return json.data;
}

const path = process.argv[2];
if (!path) throw new Error("usage: node avatar-intake.mjs <file>");
const bytes = await readFile(path);

if (bytes.byteLength > MAX_BYTES) throw new Error(`rejected: ${bytes.byteLength} bytes > ${MAX_BYTES}`);
const kind = sniff(bytes);
if (!kind) throw new Error("rejected: not a PNG, JPEG or WebP by magic number");

const base64 = bytes.toString("base64");
const meta = await call("https://api.infrai.cc/v1/image/metadata", { image: { base64 } });
if (meta.width > 6000 || meta.height > 6000) throw new Error(`rejected: ${meta.width}x${meta.height} is not an avatar`);
if (meta.exif) console.warn("stripping exif tags:", Object.keys(meta.exif).join(","));

const avatar = await call("https://api.infrai.cc/v1/image/process", {
  image: { base64 },
  ops: [{ op: "resize", params: { width: 256, height: 256, fit: "cover" } }],
  format: "webp",
});
console.log(`${kind} ${meta.size_bytes}B -> webp ${avatar.size_bytes}B, sha256 ${avatar.sha256}`);

Ask the bytes what they are — it’s a free call

POST /v1/image/metadata decodes the image and hands back dimensions, format, byte count and EXIF, without storing anything.

export INFRAI_API_KEY="your_infrai_api_key"
IMG=$(base64 < ./phone-photo.jpg | tr -d '\n')

curl -sS -X POST "https://api.infrai.cc/v1/image/metadata" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d "{\"image\":{\"base64\":\"${IMG}\"}}"
{
  "ok": true,
  "data": {
    "width": 1200,
    "height": 900,
    "format": "jpeg",
    "size_bytes": 17805,
    "exif": { "271": "TestCam", "272": "Model9", "274": "6" },
    "color_space": "sRGB",
    "has_alpha": false
  }
}

Tag 274 is orientation, 271 and 272 are camera make and model. On a phone photo you’ll often find GPS tags in there too, which is a good argument for re-encoding avatars whether or not you care about file size.

One limitation to know before you lean on this route as a validator. In our testing on 2026-07-26, posting bytes that were not an image at all — 1 KB of /dev/urandom, a fragment of a PDF — returned HTTP 200 with a default-looking 512x512 PNG description rather than an IMAGE_INPUT_INVALID error. The size_bytes field echoed the real payload size, but format and dimensions did not describe anything real. Treat metadata as describe this image, not is this an image. Your magic-number check is the type gate.

Re-encode, and never keep what they sent

POST /v1/image/process runs an ordered pipeline and re-encodes once at the end. A 256×256 WebP produced from an attacker’s file contains none of the attacker’s file — no trailing archive, no EXIF, no polyglot.

curl -sS -X POST "https://api.infrai.cc/v1/image/process" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d "{\"image\":{\"base64\":\"${IMG}\"},\"ops\":[{\"op\":\"resize\",\"params\":{\"width\":256,\"height\":256,\"fit\":\"cover\"}},{\"op\":\"compress\",\"params\":{\"quality\":82}}],\"format\":\"webp\"}"
{
  "ok": true,
  "data": {
    "image_id": "pim_a5e475e70968bda25655307e",
    "format": "webp",
    "width": 256,
    "height": 256,
    "size_bytes": 200,
    "sha256": "cc1056a63a48e6384c5f931b3fb3d183ec33365d32a912fda89486160ff3a415",
    "original_sha256": "0a4a836d0bf6b97592f3b20d0883df8f13aa0b0be2494ac1dec24741b2b9ae00",
    "ops_applied": ["resize(256x256,fit=cover)", "compress(q=82,fmt=auto)", "format_convert(webp,q=90)"]
  }
}

That 17,805-byte JPEG came back as 200 bytes. Keep original_sha256 in your users table and the free-file-host game stops being interesting: the same file re-uploaded under ten accounts collides on one hash, and you can rate-limit distinct hashes per user per day rather than requests.

Since store defaults to false, nothing is retained — the result comes back inline and Infrai keeps no copy. That’s the right default for an avatar you’re about to write into your own bucket.

To prove the strip actually happened, feed the output back through metadata and look for a null exif:

OUT=$(curl -sS -X POST "https://api.infrai.cc/v1/image/process" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d "{\"image\":{\"base64\":\"${IMG}\"},\"ops\":[{\"op\":\"resize\",\"params\":{\"width\":256,\"height\":256}}],\"format\":\"webp\"}" \
  | jq -r '.data.url' | cut -d, -f2)

curl -sS -X POST "https://api.infrai.cc/v1/image/metadata" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d "{\"image\":{\"base64\":\"${OUT}\"}}" | jq '.data | {format, width, exif}'

What it costs, and where the platform won’t help

Verified 2026-07-26: POST /v1/image/metadata and POST /v1/image/process are free, rate-limited calls that don’t draw down the new-account trial. The paid neighbours are POST /v1/image/smart_crop at $0.015 per call and POST /v1/image/background_remove at $0.05 per call. New accounts get $2 of credit. Rates on this platform move down over time and discount campaigns run, so check rather than trust this paragraph:

curl -sS "https://api.infrai.cc/v1/discovery" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  | jq '.capabilities[] | select(.id | startswith("image.")) | {id, billing: .billing.price_usd, free: .billing.free}'

Now the honest boundaries. There’s no upload signature here, so if a policy-in-the-signature design is a hard requirement, you’d be better off on S3 POST policies or Cloudinary presets and using this API only after the file lands. We could not trigger a server-side size rejection either: a 30 MB decoded payload still processed, taking roughly 74 seconds, and 12 MB took about 23 seconds. That’s your cap to set, not the platform’s. And the AI-side routes for screening picture contentPOST /v1/image/moderate, POST /v1/image/tag, POST /v1/image/ocr — currently answer VENDOR_NOT_CONFIGURED with HTTP 503 on this account, so automated nudity or logo detection isn’t something you can switch on here today. For that you need a vendor moderation API or a vision model, wired separately.

If all you ever do is resize avatars, sharp in your own process is faster and free. The reason to make this an API call is the other half of the account: the same key runs the queue that defers the re-encode, the object store the result lands in, and the error tracking that catches the rejected uploads — one bill, one usage view, no second SDK.

References

Browse more image developer guides