Malformed base64 when saving a generated PNG to object storage
The four things that actually cause a base64 decode error on image upload, a normaliser that handles all of them, and how to prove the bytes landed intact.
When an image generation call succeeds and the upload immediately fails with a base64 decode error, the storage API is almost never the problem. The string you handed it isn’t base64, or isn’t only base64. Infrai’s object upload route takes a data_base64 field and decodes it strictly, which is the correct behaviour and also the behaviour that surfaces four very common client-side mistakes at once.
Print the first twelve characters of whatever you’re about to send. That single line resolves most of these.
export function describePayload(input) {
const kind = Buffer.isBuffer(input) ? "Buffer" : typeof input;
const asString = Buffer.isBuffer(input) ? input.toString("latin1") : String(input ?? "");
const head = asString.slice(0, 12);
const clean = /^[A-Za-z0-9+/]+={0,2}$/.test(asString.replace(/\s+/g, ""));
let magic = "unknown";
if (head.startsWith("iVBORw0KGgo")) magic = "base64 PNG";
else if (head.startsWith("/9j/")) magic = "base64 JPEG";
else if (head.startsWith("UklGR")) magic = "base64 WebP";
else if (head.startsWith("data:")) magic = "data URL, prefix still attached";
else if (asString.charCodeAt(0) === 0x89 && asString.slice(1, 4) === "PNG") magic = "raw PNG bytes, not encoded yet";
else if (head.trimStart().startsWith("{")) magic = "JSON — you kept the envelope, not the field";
return { kind, length: asString.length, head, base64CharsetOnly: clean, magic };
}
console.log(describePayload(process.argv[2] ?? ""));
The four ways the string gets mangled
| Symptom | Cause | Fix |
|---|---|---|
String starts data:image/png;base64, | You passed a data URL straight through | Split on the first comma, keep the tail |
String starts { or [ | You base64’d the whole provider response instead of the image field | Read b64_json (or fetch the url) before encoding |
| Decodes to something 33% too long | Double encoding — the value was already base64 and you called .toString("base64") on it again | Encode once, at the boundary |
| Fails only for large images | Newlines from base64 CLI wrapping at 76 columns, or a pretty-printed JSON payload | Strip all whitespace before sending |
The third one is the sneakiest, because it doesn’t throw at encode time. A base64 string re-encoded is still valid base64 — it just decodes to base64 text instead of a PNG, so the object uploads happily and every image in your gallery renders as a broken icon. Checking the magic bytes catches it; checking that the upload returned 200 does not.
A normaliser that handles all four
One function, applied once, at the moment you hand bytes to storage.
export function toBase64(input) {
if (Buffer.isBuffer(input)) return input.toString("base64");
if (input instanceof Uint8Array) return Buffer.from(input).toString("base64");
if (typeof input !== "string") throw new TypeError(`unsupported image payload: ${typeof input}`);
let s = input.trim();
if (s.startsWith("data:")) {
const comma = s.indexOf(",");
if (comma === -1) throw new Error("data URL has no comma separator");
s = s.slice(comma + 1);
}
s = s.replace(/\s+/g, "");
if (s.length % 4 !== 0) throw new Error(`base64 length ${s.length} is not a multiple of 4`);
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(s)) throw new Error("payload contains non-base64 characters");
const decoded = Buffer.from(s, "base64");
const isPng = decoded.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]));
const isJpeg = decoded[0] === 0xff && decoded[1] === 0xd8;
const isWebp = decoded.subarray(0, 4).toString("latin1") === "RIFF";
if (!isPng && !isJpeg && !isWebp) throw new Error("decoded bytes are not PNG, JPEG or WebP");
return s;
}
Buffer.from(s, "base64") in Node is deliberately forgiving — it ignores characters outside the alphabet and returns whatever it managed to decode rather than throwing. That leniency is why a corrupted string sails through your code and dies at the API. The explicit charset and length checks above put the failure back where you can debug it.
Upload it
import { readFile } from "node:fs/promises";
import { toBase64 } from "./to-base64.mjs";
const API = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");
const BUCKET = "kb-png-0726";
export async function storeRender(objectKey, imagePayload) {
const payload = {
data_base64: toBase64(imagePayload),
content_type: "image/png",
};
const res = await fetch(`${API}/v1/storage/object/put/${BUCKET}/${objectKey}`, {
method: "PUT",
headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const json = await res.json();
if (!res.ok || !json.ok) {
throw new Error(`upload ${objectKey}: HTTP ${res.status} ${JSON.stringify(json.error ?? json)}`);
}
return json.data;
}
const bytes = await readFile("dragon.png");
console.log(await storeRender("renders/2026-07-26/dragon_b41f.png", bytes));
From a shell, keep the payload in a file. Inlining a megabyte of base64 into a -d argument is how you meet your operating system’s argument-length limit.
export INFRAI_API_KEY="your_infrai_api_key"
node -e 'const fs=require("fs");const b=fs.readFileSync("dragon.png").toString("base64");fs.writeFileSync("payload.json",JSON.stringify({data_base64:b,content_type:"image/png"}))'
curl -sS -X PUT "https://api.infrai.cc/v1/storage/object/put/kb-png-0726/renders/2026-07-26/dragon_b41f.png" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
--data-binary @payload.json
{
"ok": true,
"data": {
"bucket_id": "bkt_57a403cdc2d34424bbdf57",
"key": "renders/2026-07-26/dragon_b41f.png",
"size_bytes": 70,
"etag": "b357a19c87624c7c4d131aeeb4ae677f",
"content_type": "image/png",
"metadata": null,
"created_at": "2026-07-26T00:37:07Z",
"last_modified": null
}
}
The response shape you’re decoding
Image APIs hand back one of two things and the failure looks identical from the outside. Either the response carries the image inline as a base64 field — usually named b64_json — or it carries a short-lived URL you’re expected to fetch. Code written against one shape and pointed at the other produces exactly the “malformed base64” you’re debugging, because JSON.stringify of an object, or an https://… string, is not base64 and never was. Read the field explicitly, assert its type, and fail loudly when it’s absent rather than letting undefined become the string "undefined" on its way into the request body.
If the provider gave you a URL, fetch it into a Buffer first and let the normaliser encode once. Those URLs frequently expire in under an hour, so “store it later” is not a plan.
Retries deserve a note too. When you omit idempotency_key on the upload, Infrai derives one from the content hash — so a retry after a socket timeout writes the same bytes to the same key rather than creating a second object, and a job that runs twice costs one write. That’s the behaviour you want in a generation pipeline where the expensive step already happened and you’d rather not repeat it.
Prove the bytes are intact
A 200 means the JSON was accepted. It doesn’t mean the object is a valid image. Two free checks close that gap: head tells you the recorded size and MIME type, and pulling the object back tells you the decoded prefix.
curl -sS "https://api.infrai.cc/v1/storage/object/head/kb-png-0726/renders/2026-07-26/dragon_b41f.png" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": {
"found": true,
"status": "found",
"key": "renders/2026-07-26/dragon_b41f.png",
"size_bytes": 70,
"etag": "b357a19c87624c7c4d131aeeb4ae677f",
"content_type": "image/png",
"metadata": null,
"last_modified": "2026-07-26T00:37:07Z"
}
}
If size_bytes is about four thirds of what you expected, you double-encoded. If it’s a few bytes larger than the file on disk, a stray newline survived. Both are visible before a user ever sees the image.
curl -sS "https://api.infrai.cc/v1/storage/object/get/kb-png-0726/renders/2026-07-26/dragon_b41f.png" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
| python3 -c "import base64,json,sys; d=json.load(sys.stdin)['data']; print(base64.b64decode(d['data_base64'])[:8])"
Expect b'\x89PNG\r\n\x1a\n'. Anything else and the object is corrupt regardless of what the upload returned.
When base64 is the wrong tool entirely
JSON base64 upload is for small objects — the route isn’t recommended above 1 MB, and a 1024×1024 PNG off a modern image model is frequently 1.5 MB or more. Encoding inflates the request by a third on top of that, so you’re pushing 2 MB of JSON to store 1.5 MB of pixels.
For anything above that threshold, presign an upload slot and send the raw binary to the returned URL. There’s no encoding step, so there’s no encoding bug — which is the real fix for this class of error rather than a better validator.
The trade-off is an extra round trip and slightly more code. In practice it’s worth it above roughly 500 KB.
Limits and honest alternatives
Three things this API doesn’t do. It doesn’t transform images, so resizing stays with sharp or libvips on your side. It doesn’t support setting bucket CORS rules, so a browser can’t upload straight into the bucket and the write has to originate on your server. And a presigned link’s signature carries expiry, not authorisation — derive object keys from something unguessable and keep the real permission check in your own route.
If your entire stack already lives in one AWS account, the S3 SDK plus PutObjectCommand does this with an IAM role and no API key to rotate, and that’s a reasonable place to stay. Cloudflare R2 is the better pick when the images are public and fetched hard, since egress is free. What Infrai adds is that the queue running your generation jobs, the error tracker that catches this exact exception, and the bucket holding the output all sit behind one credential and one invoice — useful when storage is one of six things you need, and beside the point if it’s the only one.