SharePoint REST uploads: why Base64 bodies become corrupted images

You upload an image to a SharePoint document library through the classic REST endpoint — POST /_api/web/GetFolderByServerRelativeUrl('...')/Files/add(url='logo.png',overwrite=true) — and SharePoint answers 201 Created. Everything looks fine until someone opens the file: the image is broken, or it opens in a text editor as a wall of Base64 characters.

This is the signature of a body-encoding mismatch: you sent Base64 text, and SharePoint wrote exactly that text into the file. The Files/add endpoint does not decode Base64 — whatever bytes arrive in the request body become the file, byte for byte.

The symptom: 201 Created, broken file

The failure mode is uniquely misleading. The upload succeeds:

Nothing errors, so nothing prompts you to look at the body. Compare with Trello's 400 on the same mistake — SharePoint is quieter, which is why this page exists.

The root cause: the body is written as-is, byte for byte

Files/add treats the request body as the raw content of the file. The documented contract sends the file's binary bytes directly as the body, with Content-Type: application/octet-stream-style handling and a valid form digest (X-RequestDigest) when called from browser-authenticated contexts.

Base64 is not a file format; it is a text encoding of bytes. When the body contains that text:

  1. SharePoint does not look for a Content-Transfer-Encoding header and does not attempt any decode;
  2. each ASCII character of your Base64 string becomes one byte of the stored file;
  3. the stored bytes are your encoded text, not the original image.

The result is a file that is ~1.33× the size of the original, whose first bytes are the letters iVBOR (a PNG's Base64 prefix) instead of the PNG magic bytes. One hex dump of the uploaded file tells the whole story — if you see Base64 alphabet characters where you expect binary, this is the bug. The same "stored the text, not the bytes" behavior exists across cloud-storage APIs; the OneDrive failure mode is nearly identical.

Copy-paste fixes

The fix is always the same shape: decode once on your side, then send the raw bytes as the body.

curl: --data-binary

# 1. Get a digest token (needed for cookie/ADFS-style auth; app-only bearer tokens can skip this)
DIGEST=$(curl -s -X POST \
  "https://contoso.sharepoint.com/sites/mysite/_api/contextinfo" \
  -H "Authorization: Bearer ${SP_ACCESS_TOKEN}" \
  -H "Accept: application/json;odata=verbose" \
  | python3 -c "import sys,json;print(json.load(sys.stdin)['d']['GetContextWebInformation']['FormDigestValue'])")

# 2. Upload the raw bytes — --data-binary, never -d on text
curl -s -X POST \
  "https://contoso.sharepoint.com/sites/mysite/_api/web/GetFolderByServerRelativeUrl('Shared%20Documents')/Files/add(url='logo.png',overwrite=true)" \
  -H "Authorization: Bearer ${SP_ACCESS_TOKEN}" \
  -H "Accept: application/json;odata=verbose" \
  -H "Content-Type: application/octet-stream" \
  -H "X-RequestDigest: ${DIGEST}" \
  --data-binary @logo.png

The two details that matter: --data-binary (which sends the file bytes untouched — plain -d strips newlines) and no transformation of the body anywhere in the pipeline.

Node.js 18+

// node sp-upload.mjs
import { readFile } from "node:fs/promises";

const site = "https://contoso.sharepoint.com/sites/mysite";
const folder = "Shared Documents";
const token = process.env.SP_ACCESS_TOKEN;
if (!token) throw new Error("Set SP_ACCESS_TOKEN");

const bytes = await readFile("logo.png");

const url = `${site}/_api/web/GetFolderByServerRelativeUrl('${folder}')/Files/add(url='logo.png',overwrite=true)`;
const res = await fetch(url, {
  method: "POST",
  headers: {
    Authorization: `Bearer ${token}`,
    Accept: "application/json;odata=verbose",
    "Content-Type": "application/octet-stream",
  },
  body: bytes,                       // raw bytes — not a string, not JSON
});
if (!res.ok) throw new Error(`SharePoint ${res.status}: ${await res.text()}`);
const data = await res.json();
console.log(data.d.ServerRelativeUrl);

Passing bytes (a Buffer) keeps the body binary. If instead you do body: bytes.toString("base64") — or JSON.stringify anything — you have just created the corrupted-file bug this page is about.

Python: requests

import requests

site = "https://contoso.sharepoint.com/sites/mysite"
folder = "Shared Documents"
token = "YOUR_ACCESS_TOKEN"

with open("logo.png", "rb") as f:
    r = requests.post(
        f"{site}/_api/web/GetFolderByServerRelativeUrl('{folder}')/Files/add(url='logo.png',overwrite=true)",
        headers={
            "Authorization": f"Bearer {token}",
            "Accept": "application/json;odata=verbose",
            "Content-Type": "application/octet-stream",
        },
        data=f,          # file object: requests streams the raw bytes
        timeout=60,
    )
r.raise_for_status()
print(r.json()["d"]["ServerRelativeUrl"])

data=f (the file object) sends raw bytes. data=f.read() works too; json= or a Base64 string does not.

Browser: send ArrayBuffer, not a string

In browser code, the corruption usually arrives through jQuery's default request transforms or through building a binary string with String.fromCharCode. Send the ArrayBuffer directly and disable transforms:

const reader = new FileReader();
reader.onloadend = async () => {
  await fetch(endpoint, {
    method: "POST",
    headers: {
      Accept: "application/json;odata=verbose",
      "X-RequestDigest": document.getElementById("__REQUESTDIGEST").value,
    },
    body: reader.result,              // ArrayBuffer — body stays binary
  });
};
reader.readAsArrayBuffer(fileInput.files[0]);

Historical note for older codebases: jQuery's default transformRequest serializes the body, which corrupts binary uploads; the classic fix is transformRequest: [] (an empty transform chain) together with processData: false. Modern fetch with an ArrayBuffer/Blob body avoids the whole class of bugs.

Start from a Base64 string

If your image exists as Base64 — because you encoded it with the Image to Base64 tool or received it from an upstream service — decode it before the request:

const bytes = Buffer.from(rawBase64, "base64");   // decode exactly once
// ...then send `bytes` as the request body

Strip a data:image/png;base64, prefix first if present: everything before the first comma is metadata, and leaving it in makes the decode fail or produce junk. If the decoded file still will not open, the string itself may be damaged — whitespace, truncation, or double encoding; fix invalid Base64 image data is the checklist for that.

Note the asymmetry with reading: SharePoint can hand you file content in different shapes (the /$value endpoint returns raw binary), but writing has exactly one honest format — the bytes.

Pitfalls and a pre-flight checklist

FAQ

Why does SharePoint return 201 if the content is garbage?

Because the request was valid — the body was stored, as-is. The endpoint has no schema for file contents; it cannot know that iVBOR... text was supposed to be a PNG. This is why 'it returned 201' is not a test.

Does adding Content-Transfer-Encoding: base64 help?

No. Unlike MIME email, REST file endpoints here ignore that header. The bytes you send are the bytes stored.

Can I upload Base64 through Microsoft Graph instead?

No — Graph has the same behavior on its OneDrive endpoints: it stores exactly what you PUT. The fix pattern is identical: decode, then send bytes.

Related guides

The OneDrive companion problem — Graph stores Base64 as-is — uses the same fix. For broken input strings, start with invalid Base64 image data; for JSON APIs that do accept Base64 text, see the API payload guide.