What Is a Base64 Data URI — and Why Some Images Won't Decode

2026-09-17 · 1244 words

A data URI is an image that lives inside a string instead of a file. The whole thing — header, encoding flag and payload — is one line of text you can paste into a CSS rule, a JSON field, or an address bar:

data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFAAH/q842iQAAAABJRU5ErkJggg==

That single line is a 1×1 transparent PNG. Every part of it is load-bearing, and almost every "this Base64 won't decode" report traces back to one of three places: the header is malformed, the payload is damaged, or the bytes were never an image to begin with. Those are three different bugs with three different fixes, and telling them apart takes about ten seconds once you know what to look at.

The grammar, which is shorter than people expect

The format is defined by RFC 2397 and the whole thing is:

data:[<mediatype>][;base64],<data>

Only the comma is mandatory. If you omit everything before it, data:,Hello is a valid data URI whose media type defaults to text/plain;charset=US-ASCII. The ;base64 token is a flag, not a MIME parameter — it tells the parser "the payload is base64-encoded", and without it the payload is interpreted as percent-encoded text.

That distinction explains a failure people hit constantly. Take a valid image payload but drop the ;base64:

data:image/png,iVBORw0KGgoAAAANSUhEUg...

The browser now reads iVBORw0KGgo... as literal text and shows nothing. No error, no warning — the image simply doesn't appear, because as far as the parser is concerned you asked for a text file whose contents happen to look like gibberish.

The other half of the grammar is the comma. It separates the header from the payload, and it is where hand-edited strings usually break. data:image/png;base64 with no comma is not a data URI at all; the string after it is just a suffix. If you feed that to a decoder that strips "everything up to the first comma", the strip silently does nothing and you end up decoding the header too — which fails immediately on : and /, neither of which is in the Base64 alphabet.

The header is a claim, not evidence

The media type in the header is a hint written by whoever produced the string. It is frequently wrong, and it is never checked by the browser when the image is rendered inline, because the browser trusts the data: type it was given. That produces the most confusing class of bug in this area: a data URI that "works" but is wrong.

data:image/jpeg;base64,iVBORw0KGgoAAAANSUhEUg...

This one renders in most browsers — the bytes are a PNG, and browsers sniff and recover — but the declared type is image/jpeg. Save it as a file with a .jpg extension and half your image tools will refuse to open it, and a build pipeline that trusts the header will mislabel it everywhere downstream.

The robust approach is to read the signature — the first few bytes — and treat the header as a claim to be verified rather than a fact. PNG always starts with 89 50 4E 47 0D 0A 1A 0A, JPEG with FF D8 FF, GIF with 47 49 46 38, and WebP with 52 49 46 46 followed by 57 45 42 50 at offset 8. Those bytes are what the decoder actually uses. This is exactly what our Base64 to Image tool reports when the declared type and the real bytes disagree: it tells you both and renders the bytes.

The three bugs behind "it won't decode"

1. The payload is not Base64 anymore. Copy the string out of a log file and you may bring the header along, or lose the padding, or pick up a URL-encoded %3D where a = was meant. A decoder that only accepts the 64-character alphabet will stop at the first illegal character and, if it is polite, tell you where: position, line and column, and the code point. If it just says "invalid input", you are left guessing. The raw string decoder exists for this case — it takes a payload with no prefix at all.

2. The payload is damaged in a way that changes its length. Base64 encodes 3 bytes as 4 characters, so a valid string has a length divisible by 4 — or leaves a remainder of 2 or 3, which the = padding fills out. A remainder of 1 is impossible for a complete string. It means characters were lost, almost always because a line was truncated in a log, or a copy stopped early. No amount of guessing recovers the missing byte, and a decoder that "pads it anyway" is handing you a corrupted image.

3. It decoded perfectly — and it is not an image. This is the one people least expect. Decode 200 kilobytes of perfect Base64 and you can end up with a gzip stream, a PDF, a ZIP archive, or a hexadecimal string that was already text. The giveaway is in the first bytes: 1F 8B is gzip, 25 50 44 46 is PDF, 50 4B 03 04 is a ZIP entry, and a payload made only of 0-9a-f is hex, not Base64 that happens to look odd. Gzipped JSON piped through a Base64 encoder is one of the most common things to find where you expected a photo.

Ten seconds of diagnosis you can do yourself

If you have a browser console or a shell, three checks separate the cases. First, does the string even have a valid shape?

const s = 'data:image/png;base64,iVBORw0KGgo...';
const payload = s.includes(',') ? s.slice(s.indexOf(',') + 1) : s;
console.log('has prefix:', s !== payload);
console.log('length % 4:', payload.length % 4);   // 1 means truncated
console.log('illegal chars:', (payload.match(/[^A-Za-z0-9+/=]/g) || []).join(''));

Second, does it decode at all? In the browser, atob throws on anything outside the alphabet — which makes it a stricter checker than Buffer.from in Node, a point worth remembering:

try {
  const bytes = Uint8Array.from(atob(payload), (c) => c.charCodeAt(0));
  console.log('decoded bytes:', bytes.length, 'first 8:', [...bytes.slice(0, 8)].map((b) => b.toString(16).padStart(2, '0')).join(' '));
} catch (e) {
  console.log('atob rejected it:', e.name);
}

Third, are those first bytes a known image? 89 50 is PNG, ff d8 is JPEG, 47 49 is GIF, 52 49 46 is WebP. If the first bytes are 1f 8b or 50 4b, you have a compressed archive, not a picture.

In a shell the same three steps are one line each:

printf '%s' "$B64" | base64 -d | xxd | head -2

Decoding it properly, in each language

The browser needs two steps because atob returns a binary string, not bytes — and passing those characters straight into a Blob corrupts anything above 0x7F:

const b64 = uri.slice(uri.indexOf(',') + 1);
const bytes = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
const blob = new Blob([bytes], { type: 'image/png' });
img.src = URL.createObjectURL(blob);

Node does it in one, but deliberately ignores characters it does not recognise, which is why it will happily "decode" a string with spaces or stray quotes in it:

const buf = Buffer.from(b64, 'base64');   // lenient: junk is skipped, not reported
console.log(buf.subarray(0, 8).toString('hex'));

Python is the strict member of the family again, and will raise rather than guess:

import base64
raw = base64.b64decode(payload, validate=True)   # raises binascii.Error on bad input
open('out.png', 'wb').write(raw)

If you want the padding repaired rather than the input rejected, pad explicitly instead of disabling validation — payload + '=' * (-len(payload) % 4) — and only accept the result when the bytes afterwards still look like the image you expected.

Questions that come up every week

Does a data URI avoid an HTTP request? For the image itself, yes. But it is now part of whatever file contains it. Inside CSS, that means it blocks rendering with the stylesheet, cannot be cached separately, and is re-downloaded whenever the CSS changes. For a 2 KB icon that is a good trade; for a 300 KB photograph it is not.

Does the image get bigger? Always, by about a third, because Base64 packs 3 bytes into 4 characters. Unlike text, already-compressed image bytes do not shrink under gzip, so that overhead does not come back — see why Base64 makes your page 33% bigger for the numbers.

Can a data URI carry a script? In an img element, no: browsers do not execute scripts inside images, including SVG. An SVG data URI used as a document is a different story and is why you should not navigate to one.

The prefix is missing entirely. Is the string still usable? Usually yes, and that is what the bytes are for. Feed it to the raw Base64 decoder and the format gets read from the signature instead of the label — which is the only part of a data URI that cannot lie.

Related reading

Try the Base64 to Image converter →